fix(mcpd): SSE transport no longer cuts replies off after 5s

sendViaSse capped the reply read at a hard-coded 5s, so any SSE tool call
slower than that (techdocs fetch_url falling back after a Playwright
timeout) failed with 'No response received from SSE stream'. The read is
now bounded only by the exchange timeout, which defaults to 120s to match
mcplocal's TOOLCALL_TIMEOUT_MS and honours the server's
toolCallTimeoutSeconds. Hitting it returns an explicit timeout error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-11-07 13:49:46 +00:00
parent c07431c32e
commit fbd289d2ba
3 changed files with 204 additions and 119 deletions

View File

@@ -177,7 +177,7 @@ export class McpProxyService {
const url = server.externalUrl as string; const url = server.externalUrl as string;
if (server.transport === 'SSE') { if (server.transport === 'SSE') {
return sendViaSse(url, method, params); return sendViaSse(url, method, params, sseTimeoutMs(server));
} }
// STREAMABLE_HTTP (default for external) // STREAMABLE_HTTP (default for external)
@@ -257,7 +257,7 @@ export class McpProxyService {
const baseUrl = await this.resolveBaseUrl(instance, server); const baseUrl = await this.resolveBaseUrl(instance, server);
if (transport === 'SSE') { if (transport === 'SSE') {
return sendViaSse(baseUrl, method, params); return sendViaSse(baseUrl, method, params, sseTimeoutMs(server));
} }
// STREAMABLE_HTTP (default) // STREAMABLE_HTTP (default)
@@ -417,3 +417,9 @@ export class McpProxyService {
}); });
} }
} }
/** Per-server tool-call budget (toolCallTimeoutSeconds), else the SSE default. */
function sseTimeoutMs(server: McpServer): number | undefined {
const seconds = server.toolCallTimeoutSeconds as number | null;
return seconds !== null && seconds !== undefined ? seconds * 1000 : undefined;
}

View File

@@ -8,22 +8,47 @@ import type { McpProxyResponse } from '../mcp-proxy-service.js';
* *
* Each call opens a fresh SSE connection, initializes, sends the request, * Each call opens a fresh SSE connection, initializes, sends the request,
* reads the response, and closes. Session caching may be added later. * reads the response, and closes. Session caching may be added later.
*
* `timeoutMs` bounds the WHOLE exchange, including waiting for the reply.
* The default matches mcplocal's TOOLCALL_TIMEOUT_MS so a slow tool (a
* Playwright render that times out and falls back) answers instead of being
* cut off — a separate 5s cap on the reply read used to fail every call
* longer than that with "No response received from SSE stream".
*/ */
export const SSE_DEFAULT_TIMEOUT_MS = 120_000;
export async function sendViaSse( export async function sendViaSse(
baseUrl: string, baseUrl: string,
method: string, method: string,
params?: Record<string, unknown>, params?: Record<string, unknown>,
timeoutMs = 30_000, timeoutMs = SSE_DEFAULT_TIMEOUT_MS,
): Promise<McpProxyResponse> { ): Promise<McpProxyResponse> {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
return await exchange(baseUrl, method, params, controller.signal);
} catch (err) {
if (controller.signal.aborted) {
return errorResponse(`SSE request timed out after ${String(timeoutMs)}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
async function exchange(
baseUrl: string,
method: string,
params: Record<string, unknown> | undefined,
signal: AbortSignal,
): Promise<McpProxyResponse> {
// 1. GET /sse → SSE stream // 1. GET /sse → SSE stream
const sseResp = await fetch(`${baseUrl}/sse`, { const sseResp = await fetch(`${baseUrl}/sse`, {
method: 'GET', method: 'GET',
headers: { 'Accept': 'text/event-stream' }, headers: { 'Accept': 'text/event-stream' },
signal: controller.signal, signal,
}); });
if (!sseResp.ok) { if (!sseResp.ok) {
@@ -76,7 +101,7 @@ export async function sendViaSse(
clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' }, clientInfo: { name: 'mcpctl-proxy', version: '0.0.1' },
}, },
}), }),
signal: controller.signal, signal,
}); });
if (!initResp.ok) { if (!initResp.ok) {
@@ -89,7 +114,7 @@ export async function sendViaSse(
method: 'POST', method: 'POST',
headers: postHeaders, headers: postHeaders,
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
signal: controller.signal, signal,
}); });
// 5. Send the actual request // 5. Send the actual request
@@ -103,12 +128,12 @@ export async function sendViaSse(
method, method,
...(params !== undefined ? { params } : {}), ...(params !== undefined ? { params } : {}),
}), }),
signal: controller.signal, signal,
}); });
// 6. Read response from SSE stream (matched by request ID) // 6. Read response from SSE stream (matched by request ID). Bounded by
// the caller's signal only: aborting the GET fetch also ends this read.
let responseBuffer = ''; let responseBuffer = '';
const readTimeout = setTimeout(() => reader.cancel(), 5000);
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -120,7 +145,6 @@ export async function sendViaSse(
try { try {
const parsed = JSON.parse(line.slice(6)) as McpProxyResponse; const parsed = JSON.parse(line.slice(6)) as McpProxyResponse;
if (parsed.id === requestId) { if (parsed.id === requestId) {
clearTimeout(readTimeout);
reader.cancel(); reader.cancel();
return parsed; return parsed;
} }
@@ -133,12 +157,8 @@ export async function sendViaSse(
responseBuffer = respLines[respLines.length - 1] ?? ''; responseBuffer = respLines[respLines.length - 1] ?? '';
} }
clearTimeout(readTimeout);
reader.cancel(); reader.cancel();
return errorResponse('No response received from SSE stream'); return errorResponse('No response received from SSE stream');
} finally {
clearTimeout(timer);
}
} }
function errorResponse(message: string): McpProxyResponse { function errorResponse(message: string): McpProxyResponse {

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, afterEach } from 'vitest';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { sendViaSse } from '../src/services/transport/sse-client.js';
let server: http.Server | undefined;
afterEach(async () => {
if (server) {
server.closeAllConnections();
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
}
});
/** Legacy-SSE MCP server whose reply to the tools/call (id 2) arrives after `replyDelayMs`. */
async function startSlowSseServer(replyDelayMs: number): Promise<string> {
let stream: http.ServerResponse | undefined;
server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/sse') {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write('event: endpoint\ndata: /messages\n\n');
stream = res;
return;
}
let body = '';
req.on('data', (c: Buffer) => { body += c.toString(); });
req.on('end', () => {
res.writeHead(202).end();
const msg = JSON.parse(body) as { id?: number };
if (msg.id === 1) {
stream?.write(`data: ${JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} })}\n\n`);
} else if (msg.id === 2) {
setTimeout(() => {
stream?.write(`data: ${JSON.stringify({ jsonrpc: '2.0', id: 2, result: { ok: true } })}\n\n`);
}, replyDelayMs);
}
});
});
await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve()));
return `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`;
}
describe('sendViaSse', () => {
// Regression: a fixed 5s cap on the reply read failed every slower tool
// (e.g. techdocs fetch_url) with "No response received from SSE stream".
it('waits for a reply slower than 5s within the timeout', async () => {
const url = await startSlowSseServer(6_000);
const res = await sendViaSse(url, 'tools/call', { name: 'slow' }, 15_000);
expect(res.error).toBeUndefined();
expect(res.result).toEqual({ ok: true });
}, 20_000);
it('returns a timeout error when the reply exceeds timeoutMs', async () => {
const url = await startSlowSseServer(5_000);
const res = await sendViaSse(url, 'tools/call', { name: 'slow' }, 300);
expect(res.error?.message).toBe('SSE request timed out after 300ms');
}, 10_000);
});