diff --git a/mcphttp/client.go b/mcphttp/client.go index fa6658b..25cbb61 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. @@ -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 @@ -247,18 +252,47 @@ 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.mu.Lock() + c.protocol = version + c.mu.Unlock() + 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"` + } + 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 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. @@ -438,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 cc6c50a..12e7810 100644 --- a/mcphttp/client_test.go +++ b/mcphttp/client_test.go @@ -73,3 +73,163 @@ 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", "2025-03-26"} { + t.Run(supported, func(t *testing.T) { + var seen []initRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 + } + 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 c.protocol != supported { + t.Fatalf("negotiated protocol = %q", c.protocol) + } + 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 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", 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 inits []initRequest + var after []initRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + req, rec := recordRequest(r) + if req.Method == "initialize" { + 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 { + 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) + 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(inits) != 1 { + t.Fatalf("unrelated error retried: %d initialize attempts", len(inits)) + } + return + } + if err != nil { + t.Fatalf("connect: %v", err) + } + defer c.Close() + 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) + } + }) + } +}