fix(mcp): bound panel requests, request bodies and cancelled calls

Panels on one MCP server share six requests in flight; further requests
wait their turn (up to 256 waiting) instead of failing, and a waiting
request leaves the queue when its caller cancels. Held responses belong to
their own MCP server rather than to the process. /mcp request bodies must
arrive within 30 seconds. Over stdio, a client's notifications/cancelled
aborts the named request, which then gets no response.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-10-02 19:47:37 -07:00
1 parent fec93aa817
commit 857767842f
6 files changed
+310 -32

No files matched your search

+34 -8
View File
@@ -14,6 +14,8 @@ import { createLocalMcpServer } from './server.js';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
const MAX_BODY_BYTES = 1024 * 1024;
/** How long a client may take to send its request body. */
const BODY_TIMEOUT_MS = 30 * 1000;
/**
* Whether a request may reach the local MCP server. `localPort` is the port
@@ -56,7 +58,10 @@ export function isLocalMcpRequest({
}
/** Vite plugin that mounts the local MCP server at /mcp. */
export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
export function localMcpPlugin({
createServer = createLocalMcpServer,
bodyTimeoutMs = BODY_TIMEOUT_MS,
} = {}) {
const handlers = new Map();
const handlerFor = (apiBase) => {
if (!handlers.has(apiBase))
@@ -94,7 +99,10 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
};
res.on('close', onClose);
try {
const body = req.method === 'POST' ? await readBody(req) : undefined;
const body =
req.method === 'POST'
? await readBody(req, bodyTimeoutMs)
: undefined;
const request = new Request(`http://${host}/mcp`, {
method: req.method,
headers: Object.entries(req.headers).flatMap(([name, value]) =>
@@ -109,13 +117,18 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
} catch (error) {
if (disconnect.signal.aborted) return;
if (res.headersSent) return res.destroy();
res.writeHead(error?.status === 413 ? 413 : 400, {
res.writeHead([408, 413].includes(error?.status) ? error.status : 400, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(
JSON.stringify({
error: error?.status === 413 ? 'Request too large' : 'Bad request',
error:
error?.status === 413
? 'Request too large'
: error?.status === 408
? 'Request timed out'
: 'Bad request',
}),
);
} finally {
@@ -130,11 +143,21 @@ export function localMcpPlugin({ createServer = createLocalMcpServer } = {}) {
};
}
function readBody(req) {
function readBody(req, timeoutMs = BODY_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let tooLarge = false;
// A client that stops sending must not hold the request open.
const timer = setTimeout(
() =>
reject(Object.assign(new Error('Request timed out'), { status: 408 })),
timeoutMs,
);
const fail = (error) => {
clearTimeout(timer);
reject(error);
};
req.on('data', (chunk) => {
size += chunk.length;
// Keep draining without storing, so the 413 response can still be sent.
@@ -144,9 +167,12 @@ function readBody(req) {
}
tooLarge = true;
chunks.length = 0;
reject(Object.assign(new Error('Request too large'), { status: 413 }));
fail(Object.assign(new Error('Request too large'), { status: 413 }));
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
req.on('end', () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks));
});
req.on('error', fail);
});
}