Skip to content

Commit 3bf67c7

Browse files
committed
fix(server): reject duplicate in-flight request ids
1 parent ddba550 commit 3bf67c7

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

packages/server/src/server/streamableHttp.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,18 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
313313
);
314314
}
315315

316+
private findDuplicateRequestId(messages: JSONRPCMessage[]): RequestId | undefined {
317+
const requestIds = new Set<RequestId>();
318+
for (const message of messages) {
319+
if (!isJSONRPCRequest(message)) continue;
320+
if (requestIds.has(message.id) || this._requestToStreamMapping.has(message.id)) {
321+
return message.id;
322+
}
323+
requestIds.add(message.id);
324+
}
325+
return undefined;
326+
}
327+
316328
/**
317329
* Validates request headers for DNS rebinding protection.
318330
* @returns Error response if validation fails, `undefined` if validation passes.
@@ -697,7 +709,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
697709

698710
const request = req;
699711

700-
let rawMessage;
712+
let rawMessage: unknown;
701713
if (options?.parsedBody === undefined) {
702714
try {
703715
rawMessage = await req.json();
@@ -774,6 +786,13 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
774786
return new Response(null, { status: 202 });
775787
}
776788

789+
const duplicateRequestId = this.findDuplicateRequestId(messages);
790+
if (duplicateRequestId !== undefined) {
791+
const error = `Invalid Request: Request id ${String(duplicateRequestId)} is already in flight`;
792+
this.onerror?.(new Error(error));
793+
return this.createJsonErrorResponse(400, -32_600, error);
794+
}
795+
777796
// The default behavior is to use SSE streaming
778797
// but in some cases server will return JSON responses
779798
const streamId = crypto.randomUUID();

packages/server/test/server/streamableHttp.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,52 @@ describe('Zod v4', () => {
271271
});
272272
});
273273

274+
it('should reject a duplicate in-flight request id before overwriting stream mapping', async () => {
275+
let release!: () => void;
276+
const gate = new Promise<void>(resolve => {
277+
release = resolve;
278+
});
279+
mcpServer.registerTool(
280+
'hold',
281+
{ description: 'wait before returning', inputSchema: z.object({}) },
282+
async (): Promise<CallToolResult> => {
283+
await gate;
284+
return { content: [{ type: 'text', text: 'done' }] };
285+
}
286+
);
287+
288+
sessionId = await initializeServer();
289+
290+
const duplicateId = 'duplicate-1';
291+
const firstMessage: JSONRPCMessage = {
292+
jsonrpc: '2.0',
293+
method: 'tools/call',
294+
params: { name: 'hold', arguments: {} },
295+
id: duplicateId
296+
};
297+
const secondMessage: JSONRPCMessage = {
298+
jsonrpc: '2.0',
299+
method: 'tools/list',
300+
params: {},
301+
id: duplicateId
302+
};
303+
304+
let firstResponse: Response | undefined;
305+
let secondResponse: Response | undefined;
306+
try {
307+
firstResponse = await transport.handleRequest(createRequest('POST', firstMessage, { sessionId }));
308+
secondResponse = await transport.handleRequest(createRequest('POST', secondMessage, { sessionId }));
309+
310+
expect(secondResponse.status).toBe(400);
311+
const errorData = await secondResponse.json();
312+
expectErrorResponse(errorData, -32_600, /already in flight/);
313+
} finally {
314+
release();
315+
await firstResponse?.body?.cancel().catch(() => {});
316+
await secondResponse?.body?.cancel().catch(() => {});
317+
}
318+
});
319+
274320
it('should reject requests without a valid session ID', async () => {
275321
const request = createRequest('POST', TEST_MESSAGES.toolsList);
276322
const response = await transport.handleRequest(request);

0 commit comments

Comments
 (0)