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) + } + }) + } +}