diff --git a/.changeset/stateless-transport-reuse-error.md b/.changeset/stateless-transport-reuse-error.md new file mode 100644 index 0000000000..f57fe7ef71 --- /dev/null +++ b/.changeset/stateless-transport-reuse-error.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Return a JSON-RPC error and invoke `onerror` when a stateless Streamable HTTP transport instance is reused. diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 83801fd2c2..3857f2b9a7 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -66,6 +66,7 @@ export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServ * In stateless mode: * - No Session ID is included in any responses * - No session validation is performed + * - Each transport instance handles one request; create a fresh transport for each request */ export class StreamableHTTPServerTransport implements Transport { private _webStandardTransport: WebStandardStreamableHTTPServerTransport; diff --git a/src/server/webStandardStreamableHttp.ts b/src/server/webStandardStreamableHttp.ts index 1f528427c8..1d94efd492 100644 --- a/src/server/webStandardStreamableHttp.ts +++ b/src/server/webStandardStreamableHttp.ts @@ -205,6 +205,7 @@ export interface HandleRequestOptions { * In stateless mode: * - No Session ID is included in any responses * - No session validation is performed + * - Each transport instance handles one request; create a fresh transport for each request */ export class WebStandardStreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode @@ -323,7 +324,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // In stateless mode (no sessionIdGenerator), each request must use a fresh transport. // Reusing a stateless transport causes message ID collisions between clients. if (!this.sessionIdGenerator && this._hasHandledRequest) { - throw new Error('Stateless transport cannot be reused across requests. Create a new transport per request.'); + const error = 'Stateless transport cannot be reused across requests. Create a new transport per request.'; + this.onerror?.(new Error(error)); + return this.createJsonErrorResponse(500, -32000, error); } this._hasHandledRequest = true; diff --git a/test/server/streamableHttp.test.ts b/test/server/streamableHttp.test.ts index 4a4f7d8248..c61d3683be 100644 --- a/test/server/streamableHttp.test.ts +++ b/test/server/streamableHttp.test.ts @@ -1596,6 +1596,47 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(toolsResponse.status).toBe(200); }); + it('should return a JSON error when a stateless transport is reused with a pre-parsed body', async () => { + const reusedMcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + reusedMcpServer.tool('greet', 'A simple greeting tool', { name: z.string() }, async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + }); + + const reusedTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const onerror = vi.fn<(error: Error) => void>(); + reusedTransport.onerror = onerror; + await reusedMcpServer.connect(reusedTransport); + + const reusedServer = createServer(async (req, res) => { + const chunks: Uint8Array[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + const parsedBody = chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString()) : undefined; + await reusedTransport.handleRequest(req, res, parsedBody); + }); + const reusedBaseUrl = await listenOnRandomPort(reusedServer); + + try { + const initResponse = await sendPostRequest(reusedBaseUrl, TEST_MESSAGES.initialize); + expect(initResponse.status).toBe(200); + expect(initResponse.headers.get('mcp-session-id')).toBeNull(); + + onerror.mockClear(); + const toolsResponse = await sendPostRequest(reusedBaseUrl, TEST_MESSAGES.toolsList); + + expect(toolsResponse.status).toBe(500); + expect(toolsResponse.headers.get('content-type')).toContain('application/json'); + expectErrorResponse(await toolsResponse.json(), -32000, /Stateless transport cannot be reused/); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onerror.mock.calls[0]![0].message).toMatch(/Stateless transport cannot be reused/); + } finally { + reusedServer.close(); + await reusedTransport.close(); + await reusedMcpServer.close(); + } + }); + it('should handle POST requests with various session IDs in stateless mode', async () => { await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); @@ -3197,6 +3238,30 @@ describe('WebStandardStreamableHTTPServerTransport - onerror callback', () => { expect(onerrorSpy.mock.calls[0]![0]!.message).toMatch(/Server not initialized/); }); + it('should call onerror and return JSON when stateless transport is reused', async () => { + const statelessServer = new McpServer({ name: 'test', version: '1.0.0' }); + const statelessTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const statelessSpy = vi.fn<(error: Error) => void>(); + statelessTransport.onerror = statelessSpy; + await statelessServer.connect(statelessTransport); + + try { + const initResponse = await statelessTransport.handleRequest(req('POST', { body: TEST_MESSAGES.initialize })); + expect(initResponse.status).toBe(200); + + statelessSpy.mockClear(); + const response = await statelessTransport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList })); + + expect(response.status).toBe(500); + expectErrorResponse(await response.json(), -32000, /Stateless transport cannot be reused/); + expect(statelessSpy).toHaveBeenCalledTimes(1); + expect(statelessSpy.mock.calls[0]![0]!.message).toMatch(/Stateless transport cannot be reused/); + } finally { + await statelessTransport.close(); + await statelessServer.close(); + } + }); + it('should call onerror for invalid session ID', async () => { await initializeServer(); await transport.handleRequest(req('POST', { body: TEST_MESSAGES.toolsList, headers: withSession('invalid-session-id') }));