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
- Synthetic initialize — drive a fake
initialize request through the transport. Works but costs 5-10ms and triggers McpServer side effects (capability negotiation, notifications).
- EventStore-based resumption — the
eventStore hook doesn't help because it replays events, not session state.
- 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.
Problem
The
onsessioncloseddocstring inwebStandardStreamableHttp.d.ts(lines 57-67) explicitly describes a multi-node deployment pattern: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:
This works today (SDK 1.29.0) because
_initializedandsessionIdare plain TSprivatefields (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
WebStandardStreamableHTTPServerTransportandStreamableHTTPServerTransport:For the Node wrapper (
StreamableHTTPServerTransport), it would delegate tothis._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
_initializedbecomes non-writable, pinned SDK version, and the POC as a pre-upgrade regression gate.Context
sessionId+ "initialized" flag (~100 bytes)eventStore,_streamMapping,_requestResponseMapare all per-request transient and don't need rehydrationAlternatives considered
initializerequest through the transport. Works but costs 5-10ms and triggersMcpServerside effects (capability negotiation, notifications).eventStorehook doesn't help because it replays events, not session state.A public
adoptSession()method would make option 3 safe and officially supported.