diff --git a/mcp/streamable.go b/mcp/streamable.go index f24badbb..07261a7d 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -2136,14 +2136,21 @@ type streamableClientConn struct { var _ clientConnection = (*streamableClientConn)(nil) func (c *streamableClientConn) sessionUpdated(state clientSessionState) { + // 2026-07-28 removed both protocol-level sessions (SEP-2567) and the + // standalone HTTP GET SSE stream (SEP-2575). + modern := state.InitializeResult != nil && + state.InitializeResult.ProtocolVersion >= protocolVersion20260728 + c.mu.Lock() c.initializedResult = state.InitializeResult + if modern { + // A client must not echo an Mcp-Session-Id, so drop one that the initial + // response adopted before the negotiated version was known. + c.sessionID = "" + } c.mu.Unlock() - // Under SEP-2575 (protocol version >= 2026-07-28) the standalone HTTP GET - // SSE stream is removed. - if state.InitializeResult == nil || - state.InitializeResult.ProtocolVersion >= protocolVersion20260728 { + if state.InitializeResult == nil || modern { return } @@ -2371,12 +2378,18 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e if sessionID := resp.Header.Get(sessionIDHeader); sessionID != "" { c.mu.Lock() + // 2026-07-28 (SEP-2567) removed protocol-level sessions. Once the + // negotiated version is known to be modern, ignore an Mcp-Session-Id + // from a non-compliant server: don't adopt it, so it is never echoed on + // later requests or used to terminate the session with DELETE. + modern := c.initializedResult != nil && + c.initializedResult.ProtocolVersion >= protocolVersion20260728 hadSessionID := c.sessionID - if hadSessionID == "" { + if !modern && hadSessionID == "" { c.sessionID = sessionID } c.mu.Unlock() - if hadSessionID != "" && hadSessionID != sessionID { + if !modern && hadSessionID != "" && hadSessionID != sessionID { resp.Body.Close() return fmt.Errorf("mismatching session IDs %q and %q", hadSessionID, sessionID) } diff --git a/mcp/streamable_client_test.go b/mcp/streamable_client_test.go index 77ff81a2..db61abf0 100644 --- a/mcp/streamable_client_test.go +++ b/mcp/streamable_client_test.go @@ -1406,6 +1406,65 @@ func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) { } } +// TestStreamableClientConnect_ModernIgnoresSessionID verifies that on a +// 2026-07-28 session the client ignores an Mcp-Session-Id from a non-compliant +// server: protocol-level sessions were removed (SEP-2567), so later requests +// must carry no session ID and Close must not send a session-terminating +// DELETE. +func TestStreamableClientConnect_ModernIgnoresSessionID(t *testing.T) { + ctx := context.Background() + + echoResult := func(result any) func(*jsonrpc.Request) (string, int) { + return func(r *jsonrpc.Request) (string, int) { + return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK + } + } + + fake := &fakeStreamableServer{ + t: t, + responses: fakeResponses{ + // The discover response wrongly carries a session ID. + {"POST", "", methodDiscover, ""}: { + header: header{ + "Content-Type": "application/json", + sessionIDHeader: "sess-1", + }, + wantProtocolVersion: protocolVersion20260728, + responseFunc: echoResult(discoverResult), + }, + // The follow-up tools/list must arrive with no session ID. Had the + // client echoed "sess-1", this key would not match and the server + // would fail with "missing response". A DELETE carrying the session + // ID is likewise never registered, so Close sending one would fail. + {"POST", "", methodListTools, ""}: { + header: header{"Content-Type": "application/json"}, + wantProtocolVersion: protocolVersion20260728, + responseFunc: echoResult(&ListToolsResult{}), + }, + }, + } + + httpServer := httptest.NewServer(fake) + defer httpServer.Close() + + transport := &StreamableClientTransport{Endpoint: httpServer.URL} + client := NewClient(testImpl, nil) + session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if _, err := session.ListTools(ctx, nil); err != nil { + t.Fatalf("ListTools: %v", err) + } + if err := session.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + if missing := fake.missingRequests(); len(missing) > 0 { + t.Errorf("missing expected requests: %v", missing) + } +} + // TestStreamableClientConnSetMCPHeaders_ProtocolVersion covers // streamableClientConn.setMCPHeaders' selection of the Mcp-Protocol-Version // header value.