fix: cancel a local MCP tool call when its client disconnects

The /mcp route built its Request without a signal, so a tool kept working
after the client left. The request's signal now aborts when the connection
closes before the answer is sent.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent 13f8fa7c8e
commit c4a4247f70
2 files changed
+53

No files matched your search

+10
View File
@@ -64,6 +64,12 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
); );
return; 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 { try {
const body = req.method === 'POST' ? await readBody(req) : undefined; const body = req.method === 'POST' ? await readBody(req) : undefined;
const request = new Request(`http://${host}/mcp`, { const request = new Request(`http://${host}/mcp`, {
@@ -72,11 +78,13 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
value === undefined ? [] : [[name, String(value)]], value === undefined ? [] : [[name, String(value)]],
), ),
body, body,
signal: disconnect.signal,
}); });
const response = await handlerFor(`http://${host}`)(request); const response = await handlerFor(`http://${host}`)(request);
res.writeHead(response.status, Object.fromEntries(response.headers)); res.writeHead(response.status, Object.fromEntries(response.headers));
res.end(Buffer.from(await response.arrayBuffer())); res.end(Buffer.from(await response.arrayBuffer()));
} catch (error) { } catch (error) {
if (disconnect.signal.aborted) return;
if (res.headersSent) return res.destroy(); if (res.headersSent) return res.destroy();
res.writeHead(error?.status === 413 ? 413 : 400, { res.writeHead(error?.status === 413 ? 413 : 400, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -87,6 +95,8 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
error: error?.status === 413 ? 'Request too large' : 'Bad request', error: error?.status === 413 ? 'Request too large' : 'Bad request',
}), }),
); );
} finally {
res.off('close', onClose);
} }
}); });
}; };
+43
View File
@@ -121,3 +121,46 @@ test('the /mcp route answers local MCP requests and refuses others', async (t) =
405, 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);
});