fix(mcplocal): stream chat SSE through the proxy instead of buffering it #109
Reference in New Issue
Block a user
Delete Branch "fix/chat-sse-streaming"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
mcpctl chat reviewerbuffered the whole answer and printed it only when the turn finished. mcpd streams token deltas and the CLI renders them live — the single buffering point was mcplocal's catch-all /api/v1/* proxy, which read the entire SSE body via res.text() before replying (and capped every route at 30s).This lands the streaming/timeout fix (cherry-picked from feat/agentic-teams, da4752b) on main plus new cover:
Workspace: 2546 tests passed; touched files lint-clean.
🤖 Generated with Claude Code
https://claude.ai/code/session_016dNpnBqyyz9GxfznVcX2sP
`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