From 934568f33657a2c53583f0b3c921a45253dab2ca Mon Sep 17 00:00:00 2001 From: htjulia Date: Mon, 2 Nov 2026 15:40:45 +0900 Subject: [PATCH 1/2] fix(mcp): negotiate protocol version with fallback The Streamable HTTP client always offered 2025-06-18 and kept sending that MCP-Protocol-Version header after initialize, ignoring the version the server answered with. A server that only supports 2025-11-25 negotiates its own version and then rejects every later request with 400, so none of its tools load. Servers that validate the header on initialize itself reject the handshake outright, and the client never retried with another version. - Offer 2025-11-25 first and fall back to 2025-06-18 when the server rejects the version: HTTP 400 "Unsupported protocol version", JSON-RPC -32602, or an UNSUPPORTED_PROTOCOL_VERSION code - Send the protocolVersion from the initialize result on later requests - Keep the legacy HTTP+SSE transport on 2024-11-05 Closes #131 Tags: #mcp Co-Authored-By: htjulia --- mcphttp/client.go | 46 ++++++++++++---- mcphttp/client_test.go | 118 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/mcphttp/client.go b/mcphttp/client.go index fa6658b..7865390 100644 --- a/mcphttp/client.go +++ b/mcphttp/client.go @@ -28,7 +28,7 @@ import ( actool "github.com/Autumn-27/norma/tool" ) -const protocolVersion = "2025-06-18" +const protocolVersion = "2025-11-25" const legacySSEProtocolVersion = "2024-11-05" // ToolName mirrors mcp.ToolName so remote tools share the mcp__server__tool scheme. @@ -247,18 +247,42 @@ func (c *Client) readLegacySSE(r *bufio.Reader) { } func (c *Client) initialize(ctx context.Context) error { - version := c.protocol - if version == "" { - version = protocolVersion + versions := []string{protocolVersion, "2025-06-18"} + if c.legacySSE { + versions = []string{legacySSEProtocolVersion} } - if _, err := c.call(ctx, "initialize", map[string]any{ - "protocolVersion": version, - "capabilities": map[string]any{}, - "clientInfo": map[string]any{"name": "artex", "version": "0.2"}, - }); err != nil { - return err + for i, version := range versions { + c.protocol = version + result, err := c.call(ctx, "initialize", map[string]any{ + "protocolVersion": version, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "artex", "version": "0.2"}, + }) + if err != nil { + if i+1 < len(versions) && isUnsupportedVersion(err) { + continue + } + return err + } + var negotiated struct { + ProtocolVersion string `json:"protocolVersion"` + } + if json.Unmarshal(result, &negotiated) == nil && negotiated.ProtocolVersion != "" { + c.protocol = negotiated.ProtocolVersion + } + return c.notify(ctx, "notifications/initialized", map[string]any{}) } - return c.notify(ctx, "notifications/initialized", map[string]any{}) + return fmt.Errorf("mcp: no supported protocol version") +} + +// isUnsupportedVersion reports whether err is a server rejecting the offered +// protocol version: the spec's "Unsupported protocol version" (JSON-RPC -32602, +// or an HTTP 400 on the MCP-Protocol-Version header) or the +// UNSUPPORTED_PROTOCOL_VERSION code some servers return instead. +func isUnsupportedVersion(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unsupported protocol version") || + strings.Contains(msg, "unsupported_protocol_version") } // Tools lists the server's tools and adapts them to CoreTools. diff --git a/mcphttp/client_test.go b/mcphttp/client_test.go index cc6c50a..14c6b1a 100644 --- a/mcphttp/client_test.go +++ b/mcphttp/client_test.go @@ -73,3 +73,121 @@ func TestStreamableClientRequestTimeout(t *testing.T) { } _ = c3.Close() } + +func TestStreamableClientProtocolVersions(t *testing.T) { + for _, supported := range []string{"2025-11-25", "2025-06-18"} { + t.Run(supported, func(t *testing.T) { + var attempts []string + 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 { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"code":"UNSUPPORTED_PROTOCOL_VERSION"}`) + return + } + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "initialize": + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":%q,"capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID, supported) + case "notifications/initialized": + w.WriteHeader(http.StatusAccepted) + case "tools/list": + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[]}}`, req.ID) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c, err := New(context.Background(), "t", srv.URL, nil, false, 0) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer c.Close() + 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 supported == "2025-06-18" && (len(attempts) != 2 || attempts[1] != supported) { + t.Fatalf("fallback attempts = %v", attempts) + } + }) + } +} + +// 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. +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.Error(w, "Bad Request: Unsupported protocol version", http.StatusBadRequest) + }, true}, + {"json-rpc -32602", func(w http.ResponseWriter, id int) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32602,"message":"Unsupported protocol version","data":{"supported":["2025-06-18"],"requested":"2025-11-25"}}}`, id) + }, true}, + {"unrelated 401", func(w http.ResponseWriter, _ int) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var attempts []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req rpcRequest + _ = json.NewDecoder(r.Body).Decode(&req) + 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 + } + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "initialize": + fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID) + case "notifications/initialized": + w.WriteHeader(http.StatusAccepted) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + c, err := New(context.Background(), "t", srv.URL, nil, false, 0) + 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) + } + return + } + if err != nil { + t.Fatalf("connect: %v", err) + } + defer c.Close() + if len(attempts) != 2 || attempts[1] != "2025-06-18" { + t.Fatalf("fallback attempts = %v", attempts) + } + if c.protocol != "2025-06-18" { + t.Fatalf("negotiated protocol = %q", c.protocol) + } + }) + } +} From f6583b4badfa24cd680301ae74401d97deba3204 Mon Sep 17 00:00:00 2001 From: htjulia Date: Tue, 3 Nov 2026 09:24:05 +0900 Subject: [PATCH 2/2] fix(mcp): omit protocol version header on initialize The client sent MCP-Protocol-Version on the initialize request itself. The spec requires the header on requests after initialization, and the offered version already travels in the initialize body. A server that validates the header rejected the handshake unless the client happened to offer a version it supports, so connecting depended on matching the error body and only worked for the two versions the client offers. - Send the header only after initialize succeeds, with the version the server negotiated - Keep the fallback loop, newest-first order and negotiated-version persistence for servers that still reject the initialize body version - Rewrite both version tests around this, including a 2025-03-26-only server, and assert that initialize carries no version header Tags: #mcp Co-Authored-By: htjulia --- mcphttp/client.go | 23 +++++++--- mcphttp/client_test.go | 100 +++++++++++++++++++++++++++++------------ 2 files changed, 87 insertions(+), 36 deletions(-) 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) + } }) } }