Repository navigation
fix: MCP requests no longer share an AI session after the go-sdk bump to v1.8.0 - #10039
Conversation
… 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.
go-sdk\ bump to v1.8.0go-sdk bump to v1.8.0
k-anshul
left a comment
There was a problem hiding this comment.
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.MCPSessionIDHeaderwould replace the string literals inruntime/server/mcp.go:34andadmin/server/mcp.go:105and:270. - The
client.sessionID != ""guard incallRuntimeToolis now always true when reached throughmcpHandler. - The new comments in
admin/server/mcp.goandruntime/server/mcp.gorepeat 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
|
Thanks for the thorough review. Fixed in 40d1be3:
Left for follow-ups, to keep this cherry-pickable into release-0.91: |
Since go-sdk v1.8.0, a stateless streamable HTTP handler neither reads nor sets the
Mcp-Session-Idheader andServerOptions.GetSessionIDis not consulted. Rill runs both MCP servers stateless and keys AI sessions on that header, so after #10019:/mcpendpoint (initialize, tools/list, each tools/call) created its ownai_sessionsrow;This issues and preserves the header in Rill's own
MCPSessionIDMiddlewareinstead of relying on the SDK'sMCPGODEBUG=allowsessionsinstatelesscompatibility flag, which is removed in go-sdk v1.9. Not in any released version:release-0.90is on v1.4.1; intended for cherry-pick intorelease-0.91.Steps to reproduce (before the fix):
rill starta project and connect any MCP client tohttp://localhost:9009/mcp.ai_sessionsin 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
initializednotification runs in a session synthesized without the initialize params. That is left for a follow-up.Checklist: