Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stateless-transport-reuse-error.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/sdk': patch
---

Return a JSON-RPC error and invoke `onerror` when a stateless Streamable HTTP transport instance is reused.
1 change: 1 addition & 0 deletions src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/server/webStandardStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
65 changes: 65 additions & 0 deletions test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CallToolResult> => {
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);

Expand Down Expand Up @@ -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') }));
Expand Down
Loading