From 9db218d767b75f1c1cb20c162592d65fc78b6a51 Mon Sep 17 00:00:00 2001 From: kev-flex Date: Wed, 15 Apr 2026 22:30:00 -0700 Subject: [PATCH 1/3] feat(server): add replayInitialization for stateless serverless session adoption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In stateless serverless deployments (Vercel, Lambda, Workers), each HTTP request creates a fresh Server + Transport. The initialize handshake sets state (_clientCapabilities, _clientVersion, sessionId, _initialized) that is lost between requests, forcing developers to use Reflect.set hacks. Add a `replayInitialization` callback to transport options that restores session state on non-initialize requests. The transport reads the session ID from the request header, calls the callback to fetch cached state, and flows it to the server via a new `oninitialized` callback on the Transport interface. - Transport: `replayInitialization` option, `_tryReplayInitialization` helper called once in `handleRequest` before method dispatch - Transport: fix `validateSession` to recognize replayed sessions - Core: `oninitialized` callback on Transport interface (same pattern as onmessage/onclose/onerror) - Server: override `connect()` to hook oninitialized, seeding _clientCapabilities and _clientVersion via ??= - Node middleware: delegate oninitialized getter/setter to inner transport - Callback returns undefined → 404 per spec (client re-initializes) - Callback throws → 500 internal error - Race condition guard via _replayInProgress flag Closes #1658, #1882 --- packages/core/src/shared/transport.ts | 16 +- packages/core/src/util/inMemory.ts | 3 +- .../middleware/node/src/streamableHttp.ts | 21 +- packages/server/src/server/server.ts | 18 +- packages/server/src/server/streamableHttp.ts | 86 ++++- packages/server/test/server/server.test.ts | 99 +++++- .../server/test/server/streamableHttp.test.ts | 301 ++++++++++++++++++ 7 files changed, 535 insertions(+), 9 deletions(-) diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b..6b4754bec 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; +import type { ClientCapabilities, Implementation, JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -116,6 +116,20 @@ export interface Transport { */ onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + /** + * Callback invoked when session initialization state is restored by the transport. + * + * For transports that support stateless session replay (e.g., + * {@linkcode @modelcontextprotocol/server!server/streamableHttp.WebStandardStreamableHTTPServerTransport | WebStandardStreamableHTTPServerTransport}), + * this is called when a non-initialize request triggers session restoration + * via the transport's `replayInitialization` callback. + * + * The {@linkcode @modelcontextprotocol/server!server/server.Server | Server} hooks this during + * {@linkcode @modelcontextprotocol/server!server/server.Server.connect | connect()} to + * seed client capabilities and version info. + */ + oninitialized?: ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined; + /** * The session ID generated for this connection. */ diff --git a/packages/core/src/util/inMemory.ts b/packages/core/src/util/inMemory.ts index 256363c13..27f5fc650 100644 --- a/packages/core/src/util/inMemory.ts +++ b/packages/core/src/util/inMemory.ts @@ -1,6 +1,6 @@ import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; import type { Transport } from '../shared/transport.js'; -import type { AuthInfo, JSONRPCMessage, RequestId } from '../types/index.js'; +import type { AuthInfo, ClientCapabilities, Implementation, JSONRPCMessage, RequestId } from '../types/index.js'; interface QueuedMessage { message: JSONRPCMessage; @@ -18,6 +18,7 @@ export class InMemoryTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + oninitialized?: (data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void; sessionId?: string; /** diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f..b23185c17 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -10,7 +10,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import type { + AuthInfo, + ClientCapabilities, + Implementation, + JSONRPCMessage, + MessageExtraInfo, + RequestId, + Transport +} from '@modelcontextprotocol/core'; import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; @@ -130,6 +138,17 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.onmessage; } + /** + * Sets callback for session initialization replay. + */ + set oninitialized(handler: ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined) { + this._webStandardTransport.oninitialized = handler; + } + + get oninitialized(): ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined { + return this._webStandardTransport.oninitialized; + } + /** * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op * for the Streamable HTTP transport as connections are managed per-request. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4361f3e1e..e2736a898 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -31,7 +31,8 @@ import type { ServerResult, TaskManagerOptions, ToolResultContent, - ToolUseContent + ToolUseContent, + Transport } from '@modelcontextprotocol/core'; import { assertClientRequestTaskCapability, @@ -140,6 +141,21 @@ export class Server extends Protocol { } } + /** + * Attaches to the given transport, hooking the `oninitialized` callback + * to seed client capabilities and version info from replayed sessions. + */ + override async connect(transport: Transport): Promise { + const _oninitialized = transport.oninitialized; + transport.oninitialized = data => { + _oninitialized?.(data); + this._clientCapabilities ??= data.clientCapabilities; + this._clientVersion ??= data.clientVersion; + }; + + await super.connect(transport); + } + private _registerLoggingHandler(): void { this.setRequestHandler('logging/setLevel', async (request, ctx) => { const transportSessionId: string | undefined = diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 6284189dd..ca0052581 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -7,7 +7,15 @@ * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. */ -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import type { + AuthInfo, + ClientCapabilities, + Implementation, + JSONRPCMessage, + MessageExtraInfo, + RequestId, + Transport +} from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, @@ -152,6 +160,30 @@ export interface WebStandardStreamableHTTPServerTransportOptions { * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ supportedProtocolVersions?: string[]; + + /** + * Callback to restore session state for stateless deployments. + * + * Called when a non-initialize request arrives and the transport is not yet + * initialized. The transport reads the session ID from the `mcp-session-id` + * request header and passes it to this callback. + * + * Return cached client capabilities and version info to adopt the session, + * or `undefined` to reject the request. + * + * This callback is never called for `initialize` requests — those follow + * the normal handshake path. Note that {@linkcode WebStandardStreamableHTTPServerTransportOptions.onsessioninitialized | onsessioninitialized} + * is NOT called during replay — it only fires for new session creation. + * + * @param sessionId - The session ID from the `mcp-session-id` request header. + * @returns Cached client state to restore, or `undefined` if the session is unknown. + */ + replayInitialization?: ( + sessionId: string + ) => + | { clientCapabilities: ClientCapabilities; clientVersion: Implementation } + | undefined + | Promise<{ clientCapabilities: ClientCapabilities; clientVersion: Implementation } | undefined>; } /** @@ -240,11 +272,14 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _enableDnsRebindingProtection: boolean; private _retryInterval?: number; private _supportedProtocolVersions: string[]; + private _replayInitialization?: WebStandardStreamableHTTPServerTransportOptions['replayInitialization']; + private _replayInProgress = false; sessionId?: string; onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + oninitialized?: (data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void; constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { this.sessionIdGenerator = options.sessionIdGenerator; @@ -257,6 +292,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; this._retryInterval = options.retryInterval; this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + this._replayInitialization = options.replayInitialization; } /** @@ -351,6 +387,17 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return validationError; } + // Attempt stateless session replay before dispatching to method handlers. + let replayError: Response | undefined; + try { + replayError = await this._tryReplayInitialization(req); + } catch { + return this.createJsonErrorResponse(500, -32_603, 'Internal error: session replay failed'); + } + if (replayError) { + return replayError; + } + switch (req.method) { case 'POST': { return this.handlePostRequest(req, options); @@ -840,14 +887,45 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return new Response(null, { status: 200 }); } + /** + * Attempts to restore session state via the `replayInitialization` callback. + * Called once in `handleRequest()` before method dispatch. + * + * No-op when already initialized, no callback provided, or no session ID header. + * On success, sets `sessionId` and `_initialized`, then invokes `oninitialized` + * so the server can seed client capabilities and version info. + */ + private async _tryReplayInitialization(req: Request): Promise { + if (this._initialized || this._replayInProgress || !this._replayInitialization) return undefined; + + const sessionId = req.headers.get('mcp-session-id'); + if (!sessionId) return undefined; + + this._replayInProgress = true; + try { + const result = await this._replayInitialization(sessionId); + if (!result) { + // Session unknown/expired — 404 tells the client to re-initialize per spec + this.onerror?.(new Error('Session not found')); + return this.createJsonErrorResponse(404, -32_001, 'Session not found'); + } + + this.sessionId = sessionId; + this._initialized = true; + this.oninitialized?.(result); + return undefined; + } finally { + this._replayInProgress = false; + } + } + /** * Validates session ID for non-initialization requests. * Returns `Response` error if invalid, `undefined` otherwise */ private validateSession(req: Request): Response | undefined { - if (this.sessionIdGenerator === undefined) { - // If the sessionIdGenerator ID is not set, the session management is disabled - // and we don't need to validate the session ID + if (this.sessionIdGenerator === undefined && this.sessionId === undefined && !this._replayInitialization) { + // Session management is fully disabled (no generator, no adopted/replayed session, no replay callback) return undefined; } if (!this._initialized) { diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index fdb8214c5..8444678d6 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import type { ClientCapabilities, Implementation, JSONRPCMessage } from '@modelcontextprotocol/core'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; import { Server } from '../../src/server/server.js'; @@ -39,4 +39,101 @@ describe('Server', () => { await server.close(); }); }); + + describe('connect — oninitialized hook', () => { + const testCapabilities: ClientCapabilities = { sampling: {} }; + const testVersion: Implementation = { name: 'test-client', version: '2.0.0' }; + + it('should seed getClientCapabilities() when transport.oninitialized is called', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + const [, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + // Simulate transport calling oninitialized (as _tryReplayInitialization would) + serverTransport.oninitialized?.({ + clientCapabilities: testCapabilities, + clientVersion: testVersion + }); + + expect(server.getClientCapabilities()).toEqual(testCapabilities); + expect(server.getClientVersion()).toEqual(testVersion); + + await server.close(); + }); + + it('should return undefined for getClientCapabilities() when oninitialized is not called', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + const [, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + expect(server.getClientCapabilities()).toBeUndefined(); + expect(server.getClientVersion()).toBeUndefined(); + + await server.close(); + }); + + it('should be overwritten by a real initialize handshake', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + // First: seed via oninitialized + serverTransport.oninitialized?.({ + clientCapabilities: testCapabilities, + clientVersion: testVersion + }); + + expect(server.getClientCapabilities()).toEqual(testCapabilities); + + // Then: real initialize overwrites + const responsePromise = new Promise(resolve => { + clientTransport.onmessage = msg => resolve(msg); + }); + await clientTransport.start(); + + const realCapabilities: ClientCapabilities = { elicitation: { form: {} } }; + const realVersion: Implementation = { name: 'real-client', version: '3.0.0' }; + + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: realCapabilities, + clientInfo: realVersion + } + } as JSONRPCMessage); + + await responsePromise; + + expect(server.getClientCapabilities()).toEqual(realCapabilities); + expect(server.getClientVersion()).toEqual(realVersion); + + await server.close(); + }); + + it('should chain with an existing transport.oninitialized callback', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + const [, serverTransport] = InMemoryTransport.createLinkedPair(); + + const existingCallback = vi.fn(); + serverTransport.oninitialized = existingCallback; + + await server.connect(serverTransport); + + const data = { clientCapabilities: testCapabilities, clientVersion: testVersion }; + serverTransport.oninitialized?.(data); + + // Both the existing callback and the server's hook should have fired + expect(existingCallback).toHaveBeenCalledWith(data); + expect(server.getClientCapabilities()).toEqual(testCapabilities); + + await server.close(); + }); + }); }); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 7a23dd56b..db9103266 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -993,4 +993,305 @@ describe('Zod v4', () => { expect(cleanupCalls).toEqual(['stream-1']); }); }); + + describe('replayInitialization', () => { + const REPLAY_SESSION_ID = 'replayed-session-abc'; + const REPLAY_CAPABILITIES = { sampling: {}, elicitation: { form: {} } }; + const REPLAY_VERSION = { name: 'cached-client', version: '2.0.0' }; + const REPLAY_DATA = { + clientCapabilities: REPLAY_CAPABILITIES, + clientVersion: REPLAY_VERSION + }; + + it('should call replayInitialization with session ID from header on non-init POST', async () => { + const replayFn = vi.fn().mockReturnValue(REPLAY_DATA); + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: replayFn + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + await transport.handleRequest(request); + + expect(replayFn).toHaveBeenCalledWith(REPLAY_SESSION_ID); + }); + + it('should set transport sessionId and _initialized after successful replay', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => REPLAY_DATA + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + expect(transport.sessionId).toBeUndefined(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + await transport.handleRequest(request); + + expect(transport.sessionId).toBe(REPLAY_SESSION_ID); + }); + + it('should invoke oninitialized with replayed data', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => REPLAY_DATA + }); + + const oninitializedSpy = vi.fn(); + transport.oninitialized = oninitializedSpy; + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + await transport.handleRequest(request); + + // The server's connect() chains its own hook, so verify the original was also called + expect(oninitializedSpy).toHaveBeenCalledWith(REPLAY_DATA); + }); + + it('should seed server capabilities via oninitialized hook', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => REPLAY_DATA + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + expect(mcpServer.server.getClientCapabilities()).toBeUndefined(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + await transport.handleRequest(request); + + expect(mcpServer.server.getClientCapabilities()).toEqual(REPLAY_CAPABILITIES); + expect(mcpServer.server.getClientVersion()).toEqual(REPLAY_VERSION); + }); + + it('should pass validateSession after successful replay', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => REPLAY_DATA + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + })); + await mcpServer.connect(transport); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + const response = await transport.handleRequest(request); + + // Should NOT be 400 "Server not initialized" or 404 "Session not found" + expect(response.status).toBe(200); + }); + + it('should return 404 when callback returns undefined (session unknown)', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => { + return; + } + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + const response = await transport.handleRequest(request); + + // Replay returned undefined → 404 tells client to re-initialize per spec + expect(response.status).toBe(404); + const data = await response.json(); + expectErrorResponse(data, -32_001, /Session not found/); + }); + + it('should return 500 when callback throws', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => { + throw new Error('Redis connection failed'); + } + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(500); + const data = await response.json(); + expectErrorResponse(data, -32_603, /session replay failed/); + }); + + it('should not call callback when no replayInitialization is provided', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + // Without init, a non-init request should be rejected normally + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: 'some-session' + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + }); + + it('should not call callback for initialize requests', async () => { + const replayFn = vi.fn().mockReturnValue(REPLAY_DATA); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + replayInitialization: replayFn + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + // Init request has no mcp-session-id header → callback is not called + const request = createRequest('POST', TEST_MESSAGES.initialize); + await transport.handleRequest(request); + + expect(replayFn).not.toHaveBeenCalled(); + }); + + it('should not call callback when transport is already initialized', async () => { + const replayFn = vi.fn().mockReturnValue(REPLAY_DATA); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + replayInitialization: replayFn + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + // Normal init first + const initRequest = createRequest('POST', TEST_MESSAGES.initialize); + const initResponse = await transport.handleRequest(initRequest); + const sessionId = initResponse.headers.get('mcp-session-id')!; + + // Subsequent request should NOT trigger replay + const toolsRequest = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId }); + await transport.handleRequest(toolsRequest); + + expect(replayFn).not.toHaveBeenCalled(); + }); + + it('should not call callback when mcp-session-id header is missing', async () => { + const replayFn = vi.fn().mockReturnValue(REPLAY_DATA); + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: replayFn + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + // POST without session ID header + const request = createRequest('POST', TEST_MESSAGES.toolsList); + await transport.handleRequest(request); + + expect(replayFn).not.toHaveBeenCalled(); + }); + + it('should reject mismatched session ID after replay', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => REPLAY_DATA + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + // First request replays with REPLAY_SESSION_ID + const request1 = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + await transport.handleRequest(request1); + + // Second request with a different session ID should be rejected + const request2 = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: 'wrong-session-id' + }); + const response2 = await transport.handleRequest(request2); + + expect(response2.status).toBe(404); + const data = await response2.json(); + expectErrorResponse(data, -32_001, /Session not found/); + }); + + it('should work with GET requests', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => REPLAY_DATA + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + const request = createRequest('GET', undefined, { + sessionId: REPLAY_SESSION_ID + }); + const response = await transport.handleRequest(request); + + // Should open an SSE stream, not reject + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(transport.sessionId).toBe(REPLAY_SESSION_ID); + }); + + it('should work with DELETE requests', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: () => REPLAY_DATA + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + await mcpServer.connect(transport); + + const request = createRequest('DELETE', undefined, { + sessionId: REPLAY_SESSION_ID + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + }); + + it('should support async callback', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + replayInitialization: async sessionId => { + // Simulate async cache lookup + await new Promise(resolve => setTimeout(resolve, 10)); + return sessionId === REPLAY_SESSION_ID ? REPLAY_DATA : undefined; + } + }); + + const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); + mcpServer.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + })); + await mcpServer.connect(transport); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { + sessionId: REPLAY_SESSION_ID + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(mcpServer.server.getClientCapabilities()).toEqual(REPLAY_CAPABILITIES); + }); + }); }); From 3f12390de4a2a02f3ca85b8d8706152b9c39cb54 Mon Sep 17 00:00:00 2001 From: kev-flex Date: Wed, 15 Apr 2026 22:41:56 -0700 Subject: [PATCH 2/3] undo change --- packages/server/test/server/streamableHttp.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index db9103266..7b0f9f95d 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -1099,9 +1099,7 @@ describe('Zod v4', () => { it('should return 404 when callback returns undefined (session unknown)', async () => { const transport = new WebStandardStreamableHTTPServerTransport({ - replayInitialization: () => { - return; - } + replayInitialization: () => undefined }); const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); From 0ae44bfdd80bbe8049313c226c9c3c45a3d2508d Mon Sep 17 00:00:00 2001 From: kev-flex Date: Wed, 15 Apr 2026 23:16:52 -0700 Subject: [PATCH 3/3] naming is hard --- packages/core/src/shared/transport.ts | 2 +- packages/core/src/util/inMemory.ts | 2 +- .../middleware/node/src/streamableHttp.ts | 10 ++++++---- packages/server/src/server/server.ts | 8 ++++---- packages/server/src/server/streamableHttp.ts | 6 +++--- packages/server/test/server/server.test.ts | 20 +++++++++---------- .../server/test/server/streamableHttp.test.ts | 10 +++++----- 7 files changed, 30 insertions(+), 28 deletions(-) diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 6b4754bec..9e893c71b 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -128,7 +128,7 @@ export interface Transport { * {@linkcode @modelcontextprotocol/server!server/server.Server.connect | connect()} to * seed client capabilities and version info. */ - oninitialized?: ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined; + oninitializationreplay?: ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined; /** * The session ID generated for this connection. diff --git a/packages/core/src/util/inMemory.ts b/packages/core/src/util/inMemory.ts index 27f5fc650..48238bfe0 100644 --- a/packages/core/src/util/inMemory.ts +++ b/packages/core/src/util/inMemory.ts @@ -18,7 +18,7 @@ export class InMemoryTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; - oninitialized?: (data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void; + oninitializationreplay?: (data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void; sessionId?: string; /** diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index b23185c17..194a7e127 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -141,12 +141,14 @@ export class NodeStreamableHTTPServerTransport implements Transport { /** * Sets callback for session initialization replay. */ - set oninitialized(handler: ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined) { - this._webStandardTransport.oninitialized = handler; + set oninitializationreplay( + handler: ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined + ) { + this._webStandardTransport.oninitializationreplay = handler; } - get oninitialized(): ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined { - return this._webStandardTransport.oninitialized; + get oninitializationreplay(): ((data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void) | undefined { + return this._webStandardTransport.oninitializationreplay; } /** diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index e2736a898..451fc49dc 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -142,13 +142,13 @@ export class Server extends Protocol { } /** - * Attaches to the given transport, hooking the `oninitialized` callback + * Attaches to the given transport, hooking the `oninitializationreplay` callback * to seed client capabilities and version info from replayed sessions. */ override async connect(transport: Transport): Promise { - const _oninitialized = transport.oninitialized; - transport.oninitialized = data => { - _oninitialized?.(data); + const _oninitializationreplay = transport.oninitializationreplay; + transport.oninitializationreplay = data => { + _oninitializationreplay?.(data); this._clientCapabilities ??= data.clientCapabilities; this._clientVersion ??= data.clientVersion; }; diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index ca0052581..f32d089ad 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -279,7 +279,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - oninitialized?: (data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void; + oninitializationreplay?: (data: { clientCapabilities: ClientCapabilities; clientVersion: Implementation }) => void; constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { this.sessionIdGenerator = options.sessionIdGenerator; @@ -892,7 +892,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { * Called once in `handleRequest()` before method dispatch. * * No-op when already initialized, no callback provided, or no session ID header. - * On success, sets `sessionId` and `_initialized`, then invokes `oninitialized` + * On success, sets `sessionId` and `_initialized`, then invokes `oninitializationreplay` * so the server can seed client capabilities and version info. */ private async _tryReplayInitialization(req: Request): Promise { @@ -912,7 +912,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { this.sessionId = sessionId; this._initialized = true; - this.oninitialized?.(result); + this.oninitializationreplay?.(result); return undefined; } finally { this._replayInProgress = false; diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 8444678d6..4d2de6f9c 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -40,18 +40,18 @@ describe('Server', () => { }); }); - describe('connect — oninitialized hook', () => { + describe('connect — oninitializationreplay hook', () => { const testCapabilities: ClientCapabilities = { sampling: {} }; const testVersion: Implementation = { name: 'test-client', version: '2.0.0' }; - it('should seed getClientCapabilities() when transport.oninitialized is called', async () => { + it('should seed getClientCapabilities() when transport.oninitializationreplay is called', async () => { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); const [, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); - // Simulate transport calling oninitialized (as _tryReplayInitialization would) - serverTransport.oninitialized?.({ + // Simulate transport calling oninitializationreplay (as _tryReplayInitialization would) + serverTransport.oninitializationreplay?.({ clientCapabilities: testCapabilities, clientVersion: testVersion }); @@ -62,7 +62,7 @@ describe('Server', () => { await server.close(); }); - it('should return undefined for getClientCapabilities() when oninitialized is not called', async () => { + it('should return undefined for getClientCapabilities() when oninitializationreplay is not called', async () => { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); const [, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -80,8 +80,8 @@ describe('Server', () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); - // First: seed via oninitialized - serverTransport.oninitialized?.({ + // First: seed via oninitializationreplay + serverTransport.oninitializationreplay?.({ clientCapabilities: testCapabilities, clientVersion: testVersion }); @@ -116,18 +116,18 @@ describe('Server', () => { await server.close(); }); - it('should chain with an existing transport.oninitialized callback', async () => { + it('should chain with an existing transport.oninitializationreplay callback', async () => { const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); const [, serverTransport] = InMemoryTransport.createLinkedPair(); const existingCallback = vi.fn(); - serverTransport.oninitialized = existingCallback; + serverTransport.oninitializationreplay = existingCallback; await server.connect(serverTransport); const data = { clientCapabilities: testCapabilities, clientVersion: testVersion }; - serverTransport.oninitialized?.(data); + serverTransport.oninitializationreplay?.(data); // Both the existing callback and the server's hook should have fired expect(existingCallback).toHaveBeenCalledWith(data); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 7b0f9f95d..143c2afe4 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -1038,13 +1038,13 @@ describe('Zod v4', () => { expect(transport.sessionId).toBe(REPLAY_SESSION_ID); }); - it('should invoke oninitialized with replayed data', async () => { + it('should invoke oninitializationreplay with replayed data', async () => { const transport = new WebStandardStreamableHTTPServerTransport({ replayInitialization: () => REPLAY_DATA }); - const oninitializedSpy = vi.fn(); - transport.oninitialized = oninitializedSpy; + const oninitializationreplaySpy = vi.fn(); + transport.oninitializationreplay = oninitializationreplaySpy; const mcpServer = new McpServer({ name: 'test', version: '1.0.0' }); await mcpServer.connect(transport); @@ -1055,10 +1055,10 @@ describe('Zod v4', () => { await transport.handleRequest(request); // The server's connect() chains its own hook, so verify the original was also called - expect(oninitializedSpy).toHaveBeenCalledWith(REPLAY_DATA); + expect(oninitializationreplaySpy).toHaveBeenCalledWith(REPLAY_DATA); }); - it('should seed server capabilities via oninitialized hook', async () => { + it('should seed server capabilities via oninitializationreplay hook', async () => { const transport = new WebStandardStreamableHTTPServerTransport({ replayInitialization: () => REPLAY_DATA });