From 934568f33657a2c53583f0b3c921a45253dab2ca Mon Sep 17 00:00:00 2001 From: htjulia Date: Mon, 2 Nov 2026 15:40:45 +0900 Subject: [PATCH] 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) + } + }) + } +}