Skip to content

fix: MCP requests no longer share an AI session after the go-sdk bump to v1.8.0 - #10039

Merged
nishantmonu51 merged 2 commits into
mainfrom
nishant/fix-mcp-session-id-stateless
Oct 21, 2026
Merged

nishantmonu51 merged 2 commits into
mainfrom
nishant/fix-mcp-session-id-stateless

Conversation

@nishantmonu51

Copy link
Copy Markdown
Collaborator

Since go-sdk v1.8.0, a stateless streamable HTTP handler neither reads nor sets the Mcp-Session-Id header and ServerOptions.GetSessionID is not consulted. Rill runs both MCP servers stateless and keys AI sessions on that header, so after #10019:

  • every request to the runtime's /mcp endpoint (initialize, tools/list, each tools/call) created its own ai_sessions row;
  • the admin service's unified MCP server stopped issuing session IDs, so it never forwarded one to the runtime and every tool call from Claude or ChatGPT became its own session.

This issues and preserves the header in Rill's own MCPSessionIDMiddleware instead of relying on the SDK's MCPGODEBUG=allowsessionsinstateless compatibility flag, which is removed in go-sdk v1.9. Not in any released version: release-0.90 is on v1.4.1; intended for cherry-pick into release-0.91.

Steps to reproduce (before the fix):

  1. rill start a project and connect any MCP client to http://localhost:9009/mcp.
  2. Run two tool calls.
  3. Query ai_sessions in the project's catalog: one row per request instead of one per connection.

Note: the handshake client info was never captured in stateless mode (also before the bump), since the initialized notification runs in a session synthesized without the initialize params. That is left for a follow-up.

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

… to v1.8.0

Since go-sdk v1.8.0, a stateless streamable HTTP handler neither reads nor sets the
Mcp-Session-Id header (SEP-2567), and ServerOptions.GetSessionID is not consulted.
Rill runs both its MCP servers stateless and keys AI sessions on that header, so after
#10019 every request to the runtime's /mcp endpoint created its own ai_sessions row,
and the admin service's unified MCP server stopped issuing session IDs, so it never
forwarded one to the runtime.

Issue and preserve the header in Rill's own HTTP middleware instead of relying on the
SDK: a request without a session ID gets a new one, and every response carries the
session ID it was served with, which MCP clients echo on subsequent requests. This does
not depend on the SDK's MCPGODEBUG=allowsessionsinstateless compatibility flag, which
is removed in v1.9.

Tests assert that all the requests of one client connection share one AI session, on
both the 2025 protocol versions (initialize handshake) and the 2026-07-28 version.
@nishantmonu51 nishantmonu51 changed the title fix: MCP requests no longer share an AI session after the \go-sdk\ bump to v1.8.0 fix: MCP requests no longer share an AI session after the go-sdk bump to v1.8.0 Oct 20, 2026

@k-anshul k-anshul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. [High] Two concurrent first requests in the same session fail. runner.Session inserts the session with a plain INSERT (runtime/drivers/sqlite/catalog.go:500). If two requests with the same new session ID arrive at once, both miss in FindAISession and both insert. The second insert fails on the primary key, getServer returns nil, and the SDK responds 400 "no server available". The likely trigger is Claude making parallel tool calls on a project for the first time through the unified admin server, since both forwarded calls share the derived session ID. This was possible before the go-sdk bump too, but this PR brings back the shared-ID path. An upsert, or retrying FindAISession after the conflict, would fix it.

2. [Medium] Requests other than POST create stray session rows. The middleware issues an ID for every request without the header, and the SDK calls getServer before its 405 method check (streamable.go:348). So a GET or DELETE to /mcp (a probe, a browser, an old SSE client) inserts an ai_sessions row that is never used. Issuing IDs only for POST, or returning nil from getServer for anything other than POST, avoids this.

3. [Medium] Parallel tool calls in one session may write conflicting message indexes. Sessions span a whole connection again, so parallel tools/call requests in the same session each work from their own snapshot of the messages. They can flush messages with the same Index. ai_messages has no unique constraint on (session_id, index), so the history order becomes ambiguous.

4. [Medium] The fix only works until clients adopt the new protocol. The 2026-07-28 subtest passes because the go-sdk client sends the header back, but that protocol is sessionless (SEP-2567), so other compliant clients won't. As those clients take over, every tool call will again get its own session. This PR is a reasonable bridge, but the follow-up probably needs to group requests without the header, for example by user, client and recent activity.

5. [Low] Browser clients can't read the header. Neither runtimeProxyCORSMiddleware nor the runtime's cors.Options sets ExposedHeaders. Cross-origin browser clients therefore never see Mcp-Session-Id and never send it back.

6. [Low] Each request builds the session twice. getServer runs twice per POST (streamable.go:348 and :400). That was already the case on main, but each call now also loads the session's full message history. Building the server once and reusing it through the request context would halve the work. A follow-up would be fine for this.

7. [Low] The client-supplied session ID isn't validated. On the direct /mcp path it becomes the ai_sessions primary key and is echoed in every response. A length limit plus the spec's character set (visible ASCII, 0x21–0x7E), rejecting anything else with 400, would be safer.

[Nit]

  • Exporting the header name as middleware.MCPSessionIDHeader would replace the string literals in runtime/server/mcp.go:34 and admin/server/mcp.go:105 and :270.
  • The client.sessionID != "" guard in callRuntimeTool is now always true when reached through mcpHandler.
  • The new comments in admin/server/mcp.go and runtime/server/mcp.go repeat the middleware's doc comment and could be removed.

Developed in collaboration with Claude Code

- Retry the session lookup when the insert races with a concurrent request that addresses the same new session ID
- Return no server for non-POST MCP requests, so probes do not create AI sessions
- Reject malformed client-supplied session IDs in the middleware
- Export the header name and drop duplicate comments and a redundant guard
@nishantmonu51

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough review.

Fixed in 40d1be3:

  1. Concurrent creation: Runner.Session retries the lookup after a failed insert and continues with the session the other request created. TestSessionConcurrentCreate fails on the previous commit with the UNIQUE constraint error.
  2. Non-POST requests: the runtime's getServer returns nil for anything but POST, so the SDK's 405 is reached without creating a session. Covered in TestMCPSessionContinuity.
  3. The middleware rejects session IDs over 256 characters or outside visible ASCII with a 400.
    Nits: header name exported, duplicate comments and the redundant guard removed.

Left for follow-ups, to keep this cherry-pickable into release-0.91:
3. Duplicate indexes on parallel calls match 0.90 behavior; the clean fix is assigning the index at flush time in the catalog.
4. Agreed this is a bridge until sessionless clients take over; grouping header-less requests needs its own design.
5 and 6 are pre-existing and unrelated to the regression.

@nishantmonu51
nishantmonu51 merged commit cf5223a into main Oct 21, 2026
11 checks passed
@nishantmonu51
nishantmonu51 deleted the nishant/fix-mcp-session-id-stateless branch October 21, 2026 06:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants