From 80f70c5508d8549eac042d92484b5531bcbcd478 Mon Sep 17 00:00:00 2001 From: Grzegorz Wyszynski Date: Thu, 13 Aug 2026 13:58:39 +0200 Subject: [PATCH] mcp: clear the caller's protocol version for the initialize handshake The legacy initialize handshake pins params.protocolVersion but sends no Mcp-Protocol-Version header of its own: setMCPHeaders finds c.initializedResult still nil, since it is assigned a few lines later, and the message carries no _meta.protocolVersion, so the header falls to the request context. For a standalone client that context is empty and the header is absent; for a process that is both a server and a client it holds that process's inbound version, which contradicts the body it accompanies. This is the client-side mirror of #963. No version is negotiated at that point, so the request has none of its own to state, and the caller's must not stand in for one. Clears it for the initialize send rather than sending the proposed version: a header the peer does not support fails the handshake outright, where the body would have negotiated down. Standalone clients are unaffected, the header having already been absent there. The fake server asserted wantProtocolVersion for other methods but for none of its sixteen initialize cases. Asserts centrally that initialize carries no protocol version header, and adds a Connect whose context already holds one, without which that assertion cannot fail. Requests after negotiation are left unchecked there, since which source wins once initializedResult is set is #1162. Fixes #1164 --- mcp/client.go | 7 ++++- mcp/streamable_client_test.go | 54 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/mcp/client.go b/mcp/client.go index 74037990..a6adc6cc 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -383,7 +383,12 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp Capabilities: c.capabilities(protocolVersion), } req := &InitializeRequest{Session: cs, Params: params} - res, err := handleSend[*InitializeResult](ctx, methodInitialize, req) + // No version is negotiated yet, so this request has no version of its own to + // state; a caller's value must not stand in for one. Cleared rather than sent, + // since a header a peer does not support fails the handshake the body would + // have negotiated down. + initializeCtx := context.WithValue(ctx, protocolVersionContextKey{}, "") + res, err := handleSend[*InitializeResult](initializeCtx, methodInitialize, req) if err != nil { _ = cs.Close() return nil, err diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index a5957bf6..5bd8c601 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -145,6 +145,14 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques if v := req.Header.Get(protocolVersionHeader); v != resp.wantProtocolVersion && resp.wantProtocolVersion != "" { s.t.Errorf("%v: bad protocol version header: got %q, want %q", key, v, resp.wantProtocolVersion) } + // On initialize no version has been negotiated yet, so the request has none of + // its own to state and must not inherit the caller's. Checked for every + // initialize rather than per-response, since it holds unconditionally. + if key.jsonrpcMethod == methodInitialize { + if got := req.Header.Get(protocolVersionHeader); got != "" { + s.t.Errorf("%v: initialize carried protocol version header %q, want none", key, got) + } + } w.Write([]byte(body)) rc.Flush() // flush response @@ -175,6 +183,52 @@ func jsonBody(t *testing.T, msg jsonrpc2.Message) string { return string(data) } +// A server transport records its inbound request's version on the context it +// passes down, so a process that is both a server and a client hands its client +// leg a context already carrying a version. The initialize that leg sends has no +// negotiated version of its own, and must not present the inherited one as one. +func TestStreamableClientConnect_InitializeDoesNotInheritProtocolVersion(t *testing.T) { + ctx := context.WithValue(context.Background(), protocolVersionContextKey{}, protocolVersion20260728) + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "123", + }, + body: jsonBody(t, initResp), + }, + // Requests after negotiation are left unchecked here: which source wins + // once initializedResult is set is #1162, not this handshake. + {"POST", "123", notificationInitialized, ""}: {status: http.StatusAccepted}, + {"GET", "123", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + optional: true, + }, + {"DELETE", "123", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + // Pinned so initialize is the first request, matching the fixture's id=1. + session, err := NewClient(testImpl, nil).Connect(ctx, + &StreamableClientTransport{Endpoint: httpServer.URL}, + &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // The header on initialize is asserted absent centrally, in ServeHTTP. + if got := session.InitializeResult().ProtocolVersion; got != protocolVersion20251125 { + t.Errorf("negotiated protocol version = %q, want %q", got, protocolVersion20251125) + } +} + func TestStreamableClientTransportLifecycle(t *testing.T) { ctx := context.Background()