diff --git a/.changeset/legacy-fallback-early-abort.md b/.changeset/legacy-fallback-early-abort.md new file mode 100644 index 0000000000..145fa1c443 --- /dev/null +++ b/.changeset/legacy-fallback-early-abort.md @@ -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. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 4ef836c636..042163516e 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -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 }), diff --git a/packages/server/test/server/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts index efd1177cff..42c89c6ced 100644 --- a/packages/server/test/server/legacyStatelessFallback.test.ts +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -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');