Skip to content

Commit 3d7d68f

Browse files
Vadaskiclaude
andcommitted
fix: add existingSessionId option to WebStandardStreamableHTTPServerTransport for multi-node session hydration
Resolves issue #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>
1 parent 108f2f3 commit 3d7d68f

2 files changed

Lines changed: 217 additions & 2 deletions

File tree

packages/server/src/server/streamableHttp.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
7979
*/
8080
sessionIdGenerator?: () => string;
8181

82+
/**
83+
* If provided, reconstructs a pre-initialized transport with the given session ID.
84+
* Use this for multi-node deployments where a request may arrive at a node that did not
85+
* handle the original initialize handshake. The transport will validate incoming
86+
* mcp-session-id headers against this value without requiring a fresh initialize request.
87+
*
88+
* **Important:** This option restores transport-layer session validation only. It does not
89+
* restore MCP protocol state (e.g. negotiated client capabilities). For server-initiated
90+
* features that depend on capability negotiation (sampling, elicitation, roots), the
91+
* application must manage that state externally and configure the server accordingly.
92+
* For the common case of handling client-initiated requests (tools/call, resources/read,
93+
* etc.) this option is sufficient.
94+
*/
95+
existingSessionId?: string;
96+
8297
/**
8398
* A callback for session initialization events
8499
* This is called when the server initializes a new session.
@@ -256,6 +271,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
256271
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
257272
this._retryInterval = options.retryInterval;
258273
this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
274+
if (options.existingSessionId) {
275+
this.sessionId = options.existingSessionId;
276+
this._initialized = true;
277+
}
259278
}
260279

261280
/**
@@ -835,8 +854,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
835854
* Returns `Response` error if invalid, `undefined` otherwise
836855
*/
837856
private validateSession(req: Request): Response | undefined {
838-
if (this.sessionIdGenerator === undefined) {
839-
// If the sessionIdGenerator ID is not set, the session management is disabled
857+
if (this.sessionIdGenerator === undefined && this.sessionId === undefined) {
858+
// If sessionIdGenerator is not set and no session has been hydrated,
859+
// session management is disabled (stateless mode)
840860
// and we don't need to validate the session ID
841861
return undefined;
842862
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import type { CallToolResult, JSONRPCErrorResponse, JSONRPCMessage } from '@modelcontextprotocol/core';
2+
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
3+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4+
import * as z from 'zod/v4';
5+
6+
const TEST_MESSAGES = {
7+
initialize: {
8+
jsonrpc: '2.0',
9+
method: 'initialize',
10+
params: {
11+
clientInfo: { name: 'test-client', version: '1.0' },
12+
protocolVersion: '2025-11-25',
13+
capabilities: {}
14+
},
15+
id: 'init-1'
16+
} as JSONRPCMessage,
17+
toolsList: {
18+
jsonrpc: '2.0',
19+
method: 'tools/list',
20+
params: {},
21+
id: 'tools-1'
22+
} as JSONRPCMessage
23+
};
24+
25+
function createRequest(
26+
method: string,
27+
body?: JSONRPCMessage | JSONRPCMessage[],
28+
options?: {
29+
sessionId?: string;
30+
accept?: string;
31+
contentType?: string;
32+
extraHeaders?: Record<string, string>;
33+
}
34+
): Request {
35+
const headers: Record<string, string> = {};
36+
37+
if (options?.accept) {
38+
headers.Accept = options.accept;
39+
} else if (method === 'POST') {
40+
headers.Accept = 'application/json, text/event-stream';
41+
} else if (method === 'GET') {
42+
headers.Accept = 'text/event-stream';
43+
}
44+
45+
if (options?.contentType) {
46+
headers['Content-Type'] = options.contentType;
47+
} else if (body) {
48+
headers['Content-Type'] = 'application/json';
49+
}
50+
51+
if (options?.sessionId) {
52+
headers['mcp-session-id'] = options.sessionId;
53+
headers['mcp-protocol-version'] = '2025-11-25';
54+
}
55+
56+
if (options?.extraHeaders) {
57+
Object.assign(headers, options.extraHeaders);
58+
}
59+
60+
return new Request('http://localhost/mcp', {
61+
method,
62+
headers,
63+
body: body ? JSON.stringify(body) : undefined
64+
});
65+
}
66+
67+
async function readSSEEvent(response: Response): Promise<string> {
68+
const reader = response.body?.getReader();
69+
const { value } = await reader!.read();
70+
return new TextDecoder().decode(value);
71+
}
72+
73+
function parseSSEData(text: string): unknown {
74+
const eventLines = text.split('\n');
75+
const dataLine = eventLines.find(line => line.startsWith('data:'));
76+
if (!dataLine) {
77+
throw new Error('No data line found in SSE event');
78+
}
79+
return JSON.parse(dataLine.slice(5).trim());
80+
}
81+
82+
function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void {
83+
expect(data).toMatchObject({
84+
jsonrpc: '2.0',
85+
error: expect.objectContaining({
86+
code: expectedCode,
87+
message: expect.stringMatching(expectedMessagePattern)
88+
})
89+
});
90+
}
91+
92+
describe('WebStandardStreamableHTTPServerTransport session hydration', () => {
93+
let transport: WebStandardStreamableHTTPServerTransport;
94+
let mcpServer: McpServer;
95+
96+
beforeEach(() => {
97+
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
98+
99+
mcpServer.registerTool(
100+
'greet',
101+
{
102+
description: 'A simple greeting tool',
103+
inputSchema: z.object({ name: z.string().describe('Name to greet') })
104+
},
105+
async ({ name }): Promise<CallToolResult> => ({
106+
content: [{ type: 'text', text: `Hello, ${name}!` }]
107+
})
108+
);
109+
});
110+
111+
afterEach(async () => {
112+
await transport?.close();
113+
});
114+
115+
async function connectTransport(options?: ConstructorParameters<typeof WebStandardStreamableHTTPServerTransport>[0]) {
116+
transport = new WebStandardStreamableHTTPServerTransport(options);
117+
await mcpServer.connect(transport);
118+
}
119+
120+
it('processes requests without initialize when constructed with existingSessionId', async () => {
121+
const sessionId = 'persisted-session-id';
122+
await connectTransport({ existingSessionId: sessionId });
123+
124+
const response = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.toolsList, { sessionId }));
125+
126+
expect(response.status).toBe(200);
127+
expect(response.headers.get('mcp-session-id')).toBe(sessionId);
128+
129+
const eventData = parseSSEData(await readSSEEvent(response));
130+
expect(eventData).toMatchObject({
131+
jsonrpc: '2.0',
132+
result: expect.objectContaining({
133+
tools: expect.arrayContaining([
134+
expect.objectContaining({
135+
name: 'greet',
136+
description: 'A simple greeting tool'
137+
})
138+
])
139+
}),
140+
id: 'tools-1'
141+
});
142+
});
143+
144+
it('rejects requests with the wrong hydrated session ID', async () => {
145+
await connectTransport({ existingSessionId: 'persisted-session-id' });
146+
147+
const response = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.toolsList, { sessionId: 'wrong-session-id' }));
148+
149+
expect(response.status).toBe(404);
150+
expectErrorResponse(await response.json(), -32_001, /Session not found/);
151+
});
152+
153+
it('rejects requests with no hydrated session ID header', async () => {
154+
await connectTransport({ existingSessionId: 'persisted-session-id' });
155+
156+
const response = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.toolsList));
157+
158+
expect(response.status).toBe(400);
159+
const errorData = (await response.json()) as JSONRPCErrorResponse;
160+
expectErrorResponse(errorData, -32_000, /Mcp-Session-Id header is required/);
161+
expect(errorData.id).toBeNull();
162+
});
163+
164+
it('rejects a second initialize attempt for hydrated transports', async () => {
165+
await connectTransport({ existingSessionId: 'persisted-session-id' });
166+
167+
const response = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize));
168+
169+
expect(response.status).toBe(400);
170+
expectErrorResponse(await response.json(), -32_600, /Server already initialized/);
171+
});
172+
173+
it('keeps the default transport behavior unchanged without existingSessionId', async () => {
174+
await connectTransport({ sessionIdGenerator: () => 'generated-session-id' });
175+
176+
const initializeResponse = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize));
177+
178+
expect(initializeResponse.status).toBe(200);
179+
expect(initializeResponse.headers.get('mcp-session-id')).toBe('generated-session-id');
180+
181+
const toolsResponse = await transport.handleRequest(
182+
createRequest('POST', TEST_MESSAGES.toolsList, { sessionId: 'generated-session-id' })
183+
);
184+
185+
expect(toolsResponse.status).toBe(200);
186+
const eventData = parseSSEData(await readSSEEvent(toolsResponse));
187+
expect(eventData).toMatchObject({
188+
jsonrpc: '2.0',
189+
result: expect.objectContaining({
190+
tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })])
191+
}),
192+
id: 'tools-1'
193+
});
194+
});
195+
});

0 commit comments

Comments
 (0)