fix: add existingSessionId option to WebStandardStreamableHTTPServerTransport for multi-node session hydration#1668
Conversation
…ransport for multi-node session hydration Resolves issue modelcontextprotocol#1658. Previously, multi-node deployments had no supported way to reconstruct a session-aware transport for requests that arrive at a node that did not handle the original initialize handshake. The only workaround was stateless mode (sessionIdGenerator: undefined), which disables session ID validation entirely and violates MCP spec requirements. This adds existingSessionId? to WebStandardStreamableHTTPServerTransportOptions. When set, the transport is pre-marked as initialized with the given session ID, enabling correct mcp-session-id validation on all subsequent requests without requiring a new initialize round-trip. The validateSession() guard is updated to correctly treat a transport with a known sessionId (set via existingSessionId or normal initialization) as stateful regardless of whether sessionIdGenerator is present. Docs updated to clarify the scope limitation: this restores transport-layer session validation; MCP protocol state (negotiated client capabilities) must be managed externally by the application for server-initiated features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
| private validateSession(req: Request): Response | undefined { | ||
| if (this.sessionIdGenerator === undefined) { | ||
| // If the sessionIdGenerator ID is not set, the session management is disabled | ||
| if (this.sessionIdGenerator === undefined && this.sessionId === undefined) { |
There was a problem hiding this comment.
using sessionIdGenerator to tell if it's a stateful transport was fine before but its semantic of undefined is bit ambigious now as a transport instance could be created with sessionId set.
I don't have a good idea on top of my head but at least it could be set to () -> sessionId when a sessionId is provided in the constructor.
There was a problem hiding this comment.
Good catch — done in the latest commit. When existingSessionId is provided and no sessionIdGenerator is given, we now set this.sessionIdGenerator = () => existingSessionId so the stateless check (sessionIdGenerator === undefined) continues to unambiguously distinguish stateful from stateless mode.
There was a problem hiding this comment.
Thanks! Since the generator is guarenteed in the constructor for stateful semantics, then we should be able to revert this if check back to what it was if (this.sessionIdGenerator === undefined) {...}
There was a problem hiding this comment.
Done — reverted to if (this.sessionIdGenerator === undefined) in the latest commit. Since the constructor now guarantees sessionIdGenerator is always set when existingSessionId is provided, the single check is sufficient and the extra guard is no longer needed.
…tprotocol#1658) if (options.existingSessionId) silently ignored existingSessionId: "" — fix to !== undefined for correct empty string handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a transport is constructed with `existingSessionId`, the stateless check (`sessionIdGenerator === undefined`) was semantically ambiguous: the transport was in stateful/hydrated mode but appeared stateless. Set `sessionIdGenerator = () => existingSessionId` in the constructor when `existingSessionId` is given so the `sessionIdGenerator !== undefined` invariant correctly signals stateful mode throughout the transport lifecycle. Addresses reviewer feedback on PR modelcontextprotocol#1668. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…efined Now that the constructor always sets sessionIdGenerator when existingSessionId is provided, the extra `&& sessionId === undefined` guard in validateSession is redundant. Revert to the clean single-condition check as suggested by reviewer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Closes #1658.
Adds
existingSessionId?: stringtoWebStandardStreamableHTTPServerTransportOptions(and therebyStreamableHTTPServerTransportOptions).When set, the transport is pre-marked as initialized with the provided session ID. This allows multi-node deployments to reconstruct a session-aware transport on nodes that did not handle the original
initializerequest — enabling propermcp-session-idheader validation without a new initialize round-trip.Root cause
WebStandardStreamableHTTPServerTransportstoredsessionIdand_initializedonly as private fields set during the initialize handshake on the original transport instance. There was no supported way to restore this state on a different node.Changes
packages/server/src/server/streamableHttp.ts: AddedexistingSessionId?option field; constructor setsthis.sessionIdandthis._initialized = truewhen provided;validateSession()updated to treat a transport with a knownsessionId(from either source) as stateful.test/integration/test/server/streamableHttp.sessionHydration.test.ts(new): 5 integration tests covering hydrated-transport acceptance, wrong-session-id rejection (404), missing-session-id rejection (400), double-initialize rejection (400), and default-transport backwards compatibility.Limitation (documented)
This restores transport-layer session validation only. MCP protocol state — negotiated client capabilities set during
initialize— must be managed and restored externally by the application for server-initiated features (sampling, elicitation, roots). For the common case of handling client-initiated requests (tools/call, resources/read, etc.) this option is sufficient.The previous workaround (stateless mode with
sessionIdGenerator: undefined) disabled session validation entirely, violating the MCP spec requirement that servers validate session IDs on all non-initialize requests.Test plan
pnpm vitest run test/integration/test/server/streamableHttp.sessionHydration.test.ts— 5/5 passpnpm --filter @modelcontextprotocol/server test -- streamableHttp— 28 existing + new tests passpnpm check:all— typecheck + lint pass🤖 Generated with Claude Code