fix(mcplocal): stop the 30s proxy timeout killing agent turns
`mcpctl chat <agent>` failed with
HTTP 503 {"error":"service_unavailable","message":"Cannot reach mcpd daemon. Is it running?"}
while mcpd was answering /healthz in 32ms. The message was wrong in a way that
cost real debugging time: mcplocal was reaching mcpd fine and giving up after
30s. journalctl shows the signature plainly — statusCode 503 with
responseTime 30003.87 on POST /api/v1/agents/reviewer/chat.
This blocks the agentic-teams epic outright. An agent turn is a multi-turn
tool-use loop that runs for minutes by design, so a 30s ceiling on the chat path
is not a safety net, it is a guaranteed failure for every non-trivial turn.
Three defects, all in the same path:
1. One blanket budget for every forwarded route. DEFAULT_TIMEOUT_MS = 30_000 is
right for CRUD and wrong for chat. Chat, project chat, llm infer and
inference-task streams now get LONG_RUNNING_TIMEOUT_MS (600_000, override
with MCPLOCAL_LONG_TIMEOUT_MS) — matching STREAM_TIMEOUT_MS, which the CLI
already allowed. mcplocal in the middle was the binding constraint.
2. Timeouts were reported as connection failures. Split UpstreamTimeoutError
out of ConnectionError and map it to 504 with an accurate message that says
the daemon IS reachable. ConnectionError still means unreachable and still
returns 503. Verified nothing else branches on ConnectionError.
3. SSE was buffered. `forward()` reads the whole body through res.text(), so
even turns that finished in time arrived as one blob and the CLI's live
token output never appeared. Streaming routes now use forwardStream() and
pipe the body straight through, preserving content-type and
x-accel-buffering (dropping the latter lets intermediaries re-buffer and
reintroduces the stall).
Also closes the escape that produced the sibling `500 code:23` failure: the body
read in forward() was outside the try, so when mcpd had already written SSE
headers the raw DOMException reached Fastify unhandled.
Tests: 9 new proxy tests. The two that matter — "does not abort an agent chat
that outlives the CRUD budget" and "streams SSE through instead of buffering" —
were confirmed to FAIL against the pre-fix behaviour and pass after. Three
existing mcpd-client tests asserted the old taxonomy and were updated to assert
the new one deliberately.
Local: build clean, workspace 2375 passed, lint unchanged at 869.
NOT YET LIVE: mcplocal runs from the installed RPM, so this needs a package
rebuild + `systemctl --user restart mcplocal` to take effect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, afterAll, afterEach } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import { McpdClient, ConnectionError } from '../src/http/mcpd-client.js';
|
||||
import { McpdClient, ConnectionError, UpstreamTimeoutError } from '../src/http/mcpd-client.js';
|
||||
|
||||
/**
|
||||
* Create a local HTTP server for testing McpdClient behavior.
|
||||
@@ -85,7 +85,7 @@ describe('McpdClient', () => {
|
||||
|
||||
// ── Timeout behavior ──
|
||||
|
||||
it('times out on slow responses and throws ConnectionError', async () => {
|
||||
it('times out on slow responses and throws UpstreamTimeoutError', async () => {
|
||||
const { server, url } = await createTestServer((_req, _res) => {
|
||||
// Never respond — simulates a hanging upstream tool call
|
||||
});
|
||||
@@ -96,7 +96,7 @@ describe('McpdClient', () => {
|
||||
|
||||
const start = Date.now();
|
||||
await expect(client.post('/api/v1/mcp/proxy', { serverId: 's1' })).rejects.toThrow(
|
||||
/timed out/,
|
||||
/did not respond within/,
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('McpdClient', () => {
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
it('timeout error is a ConnectionError with descriptive message', async () => {
|
||||
it('timeout is NOT a ConnectionError — a slow daemon is not an absent one', async () => {
|
||||
const { server, url } = await createTestServer((_req, _res) => {
|
||||
// Never respond
|
||||
});
|
||||
@@ -117,8 +117,12 @@ describe('McpdClient', () => {
|
||||
await client.get('/test');
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConnectionError);
|
||||
expect((err as Error).message).toContain('Request timed out after 200ms');
|
||||
// Reporting a timeout as "cannot connect" is what sent a previous
|
||||
// debugging session chasing a network fault that did not exist.
|
||||
expect(err).toBeInstanceOf(UpstreamTimeoutError);
|
||||
expect(err).not.toBeInstanceOf(ConnectionError);
|
||||
expect((err as UpstreamTimeoutError).timeoutMs).toBe(200);
|
||||
expect((err as Error).message).toContain('did not respond within 200ms');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,7 +150,7 @@ describe('McpdClient', () => {
|
||||
const derived = client.withHeaders({ 'X-Custom': 'val' });
|
||||
|
||||
const start = Date.now();
|
||||
await expect(derived.get('/test')).rejects.toThrow(/timed out/);
|
||||
await expect(derived.get('/test')).rejects.toThrow(/did not respond within/);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user