From 824166f15a93c90afc92f3223679f546980deee2 Mon Sep 17 00:00:00 2001 From: Simon Soriano Date: Thu, 12 Nov 2026 18:03:33 +0100 Subject: [PATCH] mcp: ignore the session ID on server/discover responses A legacy server may reject server/discover with HTTP 200 and a JSON-RPC error while setting Mcp-Session-Id. The streamable client stored that ID and sent it on the fallback initialize, so a server that assigned a new session ID made Connect fail with "mismatching session IDs". server/discover is sessionless in 2026-07-28, and under 2025-11-25 the session ID belongs to the response carrying the InitializeResult, so the client no longer records a session ID from a server/discover response. Fixes #1522 Co-Authored-By: Claude Opus 5.5 --- mcp/streamable.go | 4 +- mcp/streamable_client_test.go | 105 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/mcp/streamable.go b/mcp/streamable.go index d41e420a..d0ad2635 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2660,7 +2660,9 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e return err } - if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" { + // server/discover is sessionless. A legacy server that rejects it may still + // set a session ID, which must not leak into the fallback initialize. + if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" && requestMethod != methodDiscover { c.mu.Lock() hadSessionID := c.sessionID if hadSessionID == "" { diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 7921d212..4c17b007 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1794,6 +1794,111 @@ func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) { } } +// TestStreamableClientConnect_DiscoverRejectionSessionID verifies that a +// session ID on a rejected server/discover response does not carry over to +// the fallback initialize: only the ID sent with the InitializeResult +// identifies the legacy session. +func TestStreamableClientConnect_DiscoverRejectionSessionID(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + status int + contentType string + discoverBody func(r *jsonrpc.Request) string + }{ + { + name: "200 method not found", + status: http.StatusOK, + contentType: "application/json", + discoverBody: func(r *jsonrpc.Request) string { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: &jsonrpc.Error{Code: jsonrpc.CodeMethodNotFound, Message: "method not found"}}) + }, + }, + { + name: "400 plain text", + status: http.StatusBadRequest, + contentType: "text/plain", + discoverBody: func(*jsonrpc.Request) string { return "Bad Request: Unsupported protocol version" }, + }, + { + name: "400 invalid request with unmatched id", + status: http.StatusBadRequest, + contentType: "application/json", + discoverBody: func(*jsonrpc.Request) string { + return `{"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Bad Request: Missing session ID"}}` + }, + }, + { + name: "400 invalid request with legacy supported versions", + status: http.StatusBadRequest, + contentType: "application/json", + discoverBody: func(r *jsonrpc.Request) string { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidRequest, + Message: "Unsupported protocol version", + Data: json.RawMessage(`{"supported":["2025-11-25","2025-06-18"]}`), + }}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": tt.contentType, + sessionIDHeader: "discover-session", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return tt.discoverBody(r), tt.status + }, + }, + {"POST", "", methodInitialize, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "initialize-session", + }, + responseFunc: func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(initResult)}), http.StatusOK + }, + }, + {"POST", "initialize-session", notificationInitialized, ""}: { + status: http.StatusAccepted, + }, + {"GET", "initialize-session", "", ""}: { + header: header{"Content-Type": "text/event-stream"}, + optional: true, + }, + {"DELETE", "initialize-session", "", ""}: {optional: true}, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + if got, want := session.ID(), "initialize-session"; got != want { + t.Errorf("session.ID() = %q, want %q", got, want) + } + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("did not receive expected requests: %v", missing) + } + }) + } +} + // TestStreamableClientConnect_DiscoverMethodNotFoundVPre verifies that // Client.Connect falls back to the legacy initialize handshake when a // pre-SEP-2575 (vPre) server rejects server/discover.