mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
fec93aa817
commit
857767842f
6 files changed
+310
-32
No files matched your search
+34
-8
@@ -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);
|
||||
});
|
||||
}
|
||||
+23
-4
@@ -30,13 +30,18 @@ export function describeRequest(message) {
|
||||
return typeof subject === 'string' ? `${method} ${subject}` : method;
|
||||
}
|
||||
|
||||
/** Serve newline-delimited JSON-RPC until `input` ends. */
|
||||
/**
|
||||
* Serve newline-delimited JSON-RPC until `input` ends. A client's
|
||||
* `notifications/cancelled` aborts the named request, which then gets no
|
||||
* response, as the protocol asks.
|
||||
*/
|
||||
export async function serveStdio(server, { input, output, log = () => {} }) {
|
||||
const lines = createInterface({ input, crlfDelay: Infinity });
|
||||
const pending = new Set();
|
||||
const running = new Map();
|
||||
for await (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const work = respond(server, line, log).then((response) => {
|
||||
const work = respond(server, line, log, running).then((response) => {
|
||||
if (response) output.write(`${JSON.stringify(response)}\n`);
|
||||
});
|
||||
pending.add(work);
|
||||
@@ -45,16 +50,30 @@ export async function serveStdio(server, { input, output, log = () => {} }) {
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
async function respond(server, line, log) {
|
||||
async function respond(server, line, log, running) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return parseErrorResponse();
|
||||
}
|
||||
if (message?.method === 'notifications/cancelled') {
|
||||
running.get(message.params?.requestId)?.abort();
|
||||
return null;
|
||||
}
|
||||
const described = describeRequest(message);
|
||||
if (described) log(described);
|
||||
const response = await server.handle(message);
|
||||
const id = message?.id;
|
||||
const cancellable = id !== undefined && id !== null && !running.has(id);
|
||||
const controller = new AbortController();
|
||||
if (cancellable) running.set(id, controller);
|
||||
let response;
|
||||
try {
|
||||
response = await server.handle(message, { signal: controller.signal });
|
||||
} finally {
|
||||
if (cancellable) running.delete(id);
|
||||
}
|
||||
if (controller.signal.aborted) return null;
|
||||
// A failed tool call answers the client, not the log; say why here too.
|
||||
if (response?.result?.isError)
|
||||
log(` ${described} failed: ${response.result.content?.[0]?.text}`);
|
||||
|
||||
Reference in new issue
Block a user