diff --git a/mcphttp/client.go b/mcphttp/client.go index 7865390..25cbb61 100644 --- a/mcphttp/client.go +++ b/mcphttp/client.go @@ -66,6 +66,11 @@ type Client struct { mu sync.Mutex nextID int sessionID string + // initialized is set once initialize succeeds. Until then the + // MCP-Protocol-Version header is omitted: the spec requires it on requests + // after initialization, and the offered version already travels in the + // initialize body. + initialized bool // legacySSE is the pre-2025 MCP SSE transport used by servers such as GSL5. legacySSE bool @@ -252,7 +257,9 @@ func (c *Client) initialize(ctx context.Context) error { versions = []string{legacySSEProtocolVersion} } for i, version := range versions { + c.mu.Lock() c.protocol = version + c.mu.Unlock() result, err := c.call(ctx, "initialize", map[string]any{ "protocolVersion": version, "capabilities": map[string]any{}, @@ -267,9 +274,12 @@ func (c *Client) initialize(ctx context.Context) error { var negotiated struct { ProtocolVersion string `json:"protocolVersion"` } + c.mu.Lock() if json.Unmarshal(result, &negotiated) == nil && negotiated.ProtocolVersion != "" { c.protocol = negotiated.ProtocolVersion } + c.initialized = true + c.mu.Unlock() return c.notify(ctx, "notifications/initialized", map[string]any{}) } return fmt.Errorf("mcp: no supported protocol version") @@ -462,17 +472,16 @@ func (c *Client) roundTrip(ctx context.Context, body rpcRequest, expectResp bool } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") - version := c.protocol - if version == "" { - version = protocolVersion + c.mu.Lock() + sid := c.sessionID + initialized, version := c.initialized, c.protocol + c.mu.Unlock() + if initialized && version != "" { + req.Header.Set("MCP-Protocol-Version", version) } - req.Header.Set("MCP-Protocol-Version", version) for k, v := range c.headers { req.Header.Set(k, v) } - c.mu.Lock() - sid := c.sessionID - c.mu.Unlock() if sid != "" { req.Header.Set("Mcp-Session-Id", sid) } diff --git a/mcphttp/client_test.go b/mcphttp/client_test.go index 14c6b1a..12e7810 100644 --- a/mcphttp/client_test.go +++ b/mcphttp/client_test.go @@ -74,18 +74,33 @@ func TestStreamableClientRequestTimeout(t *testing.T) { _ = c3.Close() } +// initRequest captures what the client sent for one MCP request. +type initRequest struct { + method, header, bodyVersion string +} + +func recordRequest(r *http.Request) (rpcRequest, initRequest) { + var req rpcRequest + _ = json.NewDecoder(r.Body).Decode(&req) + rec := initRequest{method: req.Method, header: r.Header.Get("MCP-Protocol-Version")} + if p, ok := req.Params.(map[string]any); ok { + rec.bodyVersion, _ = p["protocolVersion"].(string) + } + return req, rec +} + +// TestStreamableClientProtocolVersions uses spec-shaped servers that each support +// one version: they answer initialize with their own version, accept a request +// without MCP-Protocol-Version, and reject any other header value. The client +// must send no version header on initialize and the negotiated one afterwards. func TestStreamableClientProtocolVersions(t *testing.T) { - for _, supported := range []string{"2025-11-25", "2025-06-18"} { + for _, supported := range []string{"2025-11-25", "2025-06-18", "2025-03-26"} { t.Run(supported, func(t *testing.T) { - var attempts []string + var seen []initRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req rpcRequest - _ = json.NewDecoder(r.Body).Decode(&req) - version := r.Header.Get("MCP-Protocol-Version") - if req.Method == "initialize" { - attempts = append(attempts, version) - } - if version != supported { + req, rec := recordRequest(r) + seen = append(seen, rec) + if rec.header != "" && rec.header != supported { w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, `{"code":"UNSUPPORTED_PROTOCOL_VERSION"}`) return @@ -112,26 +127,43 @@ func TestStreamableClientProtocolVersions(t *testing.T) { if _, err := c.Tools(context.Background()); err != nil { t.Fatalf("tools/list: %v", err) } - if attempts[0] != protocolVersion { - t.Fatalf("first version = %q", attempts[0]) + if c.protocol != supported { + t.Fatalf("negotiated protocol = %q", c.protocol) } - if supported == "2025-06-18" && (len(attempts) != 2 || attempts[1] != supported) { - t.Fatalf("fallback attempts = %v", attempts) + var inits int + for _, rec := range seen { + if rec.method == "initialize" { + inits++ + if rec.header != "" { + t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header) + } + if rec.bodyVersion != protocolVersion { + t.Fatalf("initialize offered %q, want %q", rec.bodyVersion, protocolVersion) + } + continue + } + if rec.header != supported { + t.Fatalf("%s carried MCP-Protocol-Version %q, want %q", rec.method, rec.header, supported) + } + } + if inits != 1 { + t.Fatalf("initialize attempts = %d, want 1", inits) } }) } } -// TestStreamableClientVersionRejectionForms covers the ways a 2025-06-18-only -// server can reject 2025-11-25: the spec's HTTP 400 on the header, the spec's -// JSON-RPC -32602 error, and an unrelated failure that must not trigger a retry. +// TestStreamableClientVersionRejectionForms keeps the fallback loop honest for +// servers that reject the initialize body version instead of negotiating: an +// HTTP 400 "Unsupported protocol version", the JSON-RPC -32602 error from the +// spec's example, and an unrelated failure that must not trigger a retry. func TestStreamableClientVersionRejectionForms(t *testing.T) { cases := []struct { name string reject func(w http.ResponseWriter, id int) fallback bool }{ - {"http 400 header", func(w http.ResponseWriter, _ int) { + {"http 400", func(w http.ResponseWriter, _ int) { http.Error(w, "Bad Request: Unsupported protocol version", http.StatusBadRequest) }, true}, {"json-rpc -32602", func(w http.ResponseWriter, id int) { @@ -144,16 +176,18 @@ func TestStreamableClientVersionRejectionForms(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - var attempts []string + var inits []initRequest + var after []initRequest srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req rpcRequest - _ = json.NewDecoder(r.Body).Decode(&req) + req, rec := recordRequest(r) if req.Method == "initialize" { - attempts = append(attempts, r.Header.Get("MCP-Protocol-Version")) - } - if r.Header.Get("MCP-Protocol-Version") != "2025-06-18" { - tc.reject(w, req.ID) - return + inits = append(inits, rec) + if rec.bodyVersion != "2025-06-18" { + tc.reject(w, req.ID) + return + } + } else { + after = append(after, rec) } w.Header().Set("Content-Type", "application/json") switch req.Method { @@ -168,13 +202,18 @@ func TestStreamableClientVersionRejectionForms(t *testing.T) { defer srv.Close() c, err := New(context.Background(), "t", srv.URL, nil, false, 0) + for _, rec := range inits { + if rec.header != "" { + t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header) + } + } if !tc.fallback { if err == nil { _ = c.Close() t.Fatal("expected connect error") } - if len(attempts) != 1 { - t.Fatalf("unrelated error retried: attempts = %v", attempts) + if len(inits) != 1 { + t.Fatalf("unrelated error retried: %d initialize attempts", len(inits)) } return } @@ -182,12 +221,15 @@ func TestStreamableClientVersionRejectionForms(t *testing.T) { t.Fatalf("connect: %v", err) } defer c.Close() - if len(attempts) != 2 || attempts[1] != "2025-06-18" { - t.Fatalf("fallback attempts = %v", attempts) + if len(inits) != 2 || inits[0].bodyVersion != protocolVersion || inits[1].bodyVersion != "2025-06-18" { + t.Fatalf("fallback attempts = %+v", inits) } if c.protocol != "2025-06-18" { t.Fatalf("negotiated protocol = %q", c.protocol) } + if len(after) == 0 || after[0].method != "notifications/initialized" || after[0].header != "2025-06-18" { + t.Fatalf("requests after initialize = %+v", after) + } }) } }