diff --git a/server/mcp/plugin.js b/server/mcp/plugin.js index 4c5c4ae24..c1d3c6c94 100644 --- a/server/mcp/plugin.js +++ b/server/mcp/plugin.js @@ -64,6 +64,12 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { ); return; } + // A client that disconnects before its answer cancels the tool call. + const disconnect = new AbortController(); + const onClose = () => { + if (!res.writableFinished) disconnect.abort(); + }; + res.on('close', onClose); try { const body = req.method === 'POST' ? await readBody(req) : undefined; const request = new Request(`http://${host}/mcp`, { @@ -72,11 +78,13 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { value === undefined ? [] : [[name, String(value)]], ), body, + signal: disconnect.signal, }); const response = await handlerFor(`http://${host}`)(request); res.writeHead(response.status, Object.fromEntries(response.headers)); res.end(Buffer.from(await response.arrayBuffer())); } catch (error) { + if (disconnect.signal.aborted) return; if (res.headersSent) return res.destroy(); res.writeHead(error?.status === 413 ? 413 : 400, { 'Content-Type': 'application/json', @@ -87,6 +95,8 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) { error: error?.status === 413 ? 'Request too large' : 'Bad request', }), ); + } finally { + res.off('close', onClose); } }); }; diff --git a/src/tools/localRoute.test.mjs b/src/tools/localRoute.test.mjs index bdeb6a0e4..ed4129885 100644 --- a/src/tools/localRoute.test.mjs +++ b/src/tools/localRoute.test.mjs @@ -121,3 +121,46 @@ test('the /mcp route answers local MCP requests and refuses others', async (t) = 405, ); }); + +test('a client that disconnects cancels its tool call', async (t) => { + let middleware; + let seen; + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const plugin = localMcpPlugin({ + createServer: () => ({ + handle: (message, { signal }) => { + seen = signal; + signal.addEventListener('abort', () => aborted.resolve(), { + once: true, + }); + started.resolve(); + return new Promise(() => {}); + }, + }), + }); + plugin.configureServer({ + middlewares: { use: (path, handler) => (middleware = handler) }, + }); + const http = createServer((req, res) => middleware(req, res)); + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + t.after(() => http.close()); + const { port } = http.address(); + const request = httpRequest({ + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Host: `localhost:${port}`, + }, + }); + request.on('error', () => {}); + request.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call' })); + await started.promise; + assert.equal(seen.aborted, false); + request.destroy(); + await aborted.promise; + assert.equal(seen.aborted, true); +});