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/legacy-fallback-early-abort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

The stateless legacy fallback of `createMcpHandler` now answers `499` with no body, closes the per-request transport and server, and runs nothing when the client aborted the request while the factory or `connect()` was still running. Before, that abort was never seen: the request ran to completion and `ctx.mcpReq.signal` never aborted. The modern path already answered `499` in this case.
5 changes: 5 additions & 0 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,11 @@ function createLegacyStatelessFallback(
// Tear the per-request pair down when the client goes away before
// the exchange completes.
request.signal?.addEventListener('abort', teardown, { once: true });
// An abort that fired during the awaits above has no event left to deliver.
if (request.signal?.aborted) {
teardown();
return new Response(null, { status: 499 });
}

const response = await transport.handleRequest(request, {
...(options?.authInfo !== undefined && { authInfo: options.authInfo }),
Expand Down
33 changes: 33 additions & 0 deletions packages/server/test/server/legacyStatelessFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,39 @@ describe('legacyStatelessFallback', () => {
});
});

it('tears the per-request pair down without running the request when the client aborted before the handler subscribed', async () => {
let toolRan = false;
let productClosed = false;
const handler = legacyStatelessFallback(() => {
const mcpServer = new McpServer({ name: 'fallback-early-abort', version: '1.0.0' });
mcpServer.registerTool('echo', { inputSchema: z.object({}) }, async () => {
toolRan = true;
return { content: [{ type: 'text', text: 'ran' }] };
});
mcpServer.server.onclose = () => {
productClosed = true;
};
return mcpServer;
});

const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: {} } }),
signal: AbortSignal.abort()
});

const response = await handler(request);
expect(response.status).toBe(499);
await vi.waitFor(() => {
expect(productClosed).toBe(true);
});
expect(toolRan).toBe(false);
});

it('answers factory failures with a 500 internal error body', async () => {
const handler = legacyStatelessFallback(() => {
throw new Error('factory exploded');
Expand Down
Loading