Skip to content

fix: add existingSessionId option to WebStandardStreamableHTTPServerTransport for multi-node session hydration#1668

Open
Vadaski wants to merge 4 commits intomodelcontextprotocol:mainfrom
Vadaski:fix/1658-streamable-http-session-hydration
Open

fix: add existingSessionId option to WebStandardStreamableHTTPServerTransport for multi-node session hydration#1668
Vadaski wants to merge 4 commits intomodelcontextprotocol:mainfrom
Vadaski:fix/1658-streamable-http-session-hydration

Conversation

@Vadaski
Copy link

@Vadaski Vadaski commented Mar 12, 2026

Summary

Closes #1658.

Adds existingSessionId?: string to WebStandardStreamableHTTPServerTransportOptions (and thereby StreamableHTTPServerTransportOptions).

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 initialize request — enabling proper mcp-session-id header validation without a new initialize round-trip.

Root cause

WebStandardStreamableHTTPServerTransport stored sessionId and _initialized only 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: Added existingSessionId? option field; constructor sets this.sessionId and this._initialized = true when provided; validateSession() updated to treat a transport with a known sessionId (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 pass
  • pnpm --filter @modelcontextprotocol/server test -- streamableHttp — 28 existing + new tests pass
  • pnpm check:all — typecheck + lint pass

🤖 Generated with Claude Code

…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>
@Vadaski Vadaski requested a review from a team as a code owner March 12, 2026 06:31
@changeset-bot
Copy link

changeset-bot bot commented Mar 12, 2026

⚠️ No Changeset found

Latest commit: 4ea4610

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 12, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1668

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1668

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1668

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1668

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1668

commit: 2d23027

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) {
Copy link

@arthurchan35 arthurchan35 Mar 12, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Author

Choose a reason for hiding this comment

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

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.

Choose a reason for hiding this comment

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

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) {...}

Copy link
Author

Choose a reason for hiding this comment

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

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.

Vadaski and others added 3 commits March 12, 2026 01:53
…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>
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.

StreamableHTTPServerTransport has no public API to reconstruct a session-aware transport from persisted session data

2 participants