Skip to content

Public API for multi-node session rehydration (e.g. transport.adoptSession(id)) #1882

@technicallychudi

Description

@technicallychudi

Problem

The onsessionclosed docstring in webStandardStreamableHttp.d.ts (lines 57-67) explicitly describes a multi-node deployment pattern:

if you are handling HTTP requests from multiple nodes you might want to close each WebStandardStreamableHTTPServerTransport after a request is completed while still keeping the session open/running.

This is exactly the pattern we need — we run an MCP server on Azure App Service behind a Cloudflare Worker proxy, and every container restart (deploy) wipes in-process session state, forcing clients to re-handshake.

To rehydrate a session on a fresh transport instance, downstream servers currently have to reach through private fields:

const inner = (transport as any)._webStandardTransport;
inner._initialized = true;
inner.sessionId = existingSessionId;

This works today (SDK 1.29.0) because _initialized and sessionId are plain TS private fields (runtime-writable JS properties), but it's fragile — any move to ES # private fields or internal restructuring silently breaks it.

Proposed API

A public method on both WebStandardStreamableHTTPServerTransport and StreamableHTTPServerTransport:

/**
 * Adopt an existing session ID on this transport, bypassing the
 * initialize handshake. Use this for multi-node deployments where
 * session state is externalized (e.g. to a database) and transports
 * are constructed per-request.
 *
 * @param sessionId - A previously-initialized session ID
 */
adoptSession(sessionId: string): void {
  this._initialized = true;
  this.sessionId = sessionId;
}

For the Node wrapper (StreamableHTTPServerTransport), it would delegate to this._webStandardTransport.adoptSession(sessionId).

Evidence

We built a POC that exercises 4 scenarios (fresh transport rejection, direct monkey-patch, synthetic-init workaround, Node wrapper field-reach). All 4 pass on SDK 1.29.0.
Our production workaround includes a runtime writability assertion that crashes loudly if _initialized becomes non-writable, pinned SDK version, and the POC as a pre-upgrade regression gate.

Context

  • ADR documenting this decision: internal (ADR-017, MCP session externalization)
  • The session state that needs to persist is minimal: just sessionId + "initialized" flag (~100 bytes)
  • eventStore, _streamMapping, _requestResponseMap are all per-request transient and don't need rehydration
  • This pattern is relevant to any multi-node or serverless deployment of an MCP server

Alternatives considered

  1. Synthetic initialize — drive a fake initialize request through the transport. Works but costs 5-10ms and triggers McpServer side effects (capability negotiation, notifications).
  2. EventStore-based resumption — the eventStore hook doesn't help because it replays events, not session state.
  3. Direct private field mutation — what we're doing now, with the fragility noted above.

A public adoptSession() method would make option 3 safe and officially supported.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions