Fix streaming mode for CLI, MCP, TCP protocols and add SSE reconnection - #100
Conversation
call_tool_streaming failed for several protocols instead of emitting the full result as a single chunk like the HTTP protocol does: - CLI raised NotImplementedError on every streaming call. - MCP yielded the un-awaited coroutine instead of the result. - TCP was a plain coroutine returning a generator, so the client's `async for` failed. UDP gets the same shape and type annotation. SSE improvements: - Implement `reconnect` / `retry_timeout`, which were accepted but never acted on. When an established stream drops, reconnect after `retry_timeout` (or the server's `retry:` value) with `Last-Event-ID`, capped at MAX_RECONNECT_ATTEMPTS per call. A clean end of stream completes the call; connection or HTTP errors on the initial request still fail immediately. Redirects stay refused on every attempt. - Handle CRLF line endings and a trailing unterminated event. Tests added for every fixed protocol and for the SSE reconnect paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 10 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
mcp 2.x removed `mcp.server.fastmcp.FastMCP` and `mcp.shared.exceptions.McpError`, which the MCP test mocks import. CI installs the newest mcp, so every job failed at collection with mcp 2.1.1 (the last green run on dev predates the mcp 2 release). The plugin targets the 1.x API; a fresh install with the pin resolves to mcp 1.29.1 and the MCP suite passes. Migrating to mcp 2 is a separate task. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
CI was failing at test collection, not on any test: the runner installed mcp 2.1.1, which removed |
…ys, fix CRLF framing Addresses cubic review on #100: - The handshake (until response headers arrive) is bounded by HANDSHAKE_TIMEOUT_SECONDS (30 s) via asyncio.wait_for, so a server that accepts the connection but never answers cannot hang the call. Reading the body stays unbounded: an SSE stream may legitimately be quiet. - A reconnect handshake that fails (refused, 503, timeout) now counts as one attempt and is retried; only the initial handshake fails fast. - The reconnect delay is capped at MAX_RECONNECT_DELAY_MS (60 s) whatever retry_timeout or a server-sent retry: field asks for, so the attempt cap actually bounds the total wait. - A CRLF split across two chunks no longer becomes two LFs and ends the event early: a trailing CR is held until the next chunk. Decoding is now incremental too, so a multi-byte UTF-8 character straddling chunks no longer raises. - An event that exceeds MAX_EVENT_BUFFER_CHARS (16 Mi) without a blank-line delimiter raises SseProtocolError, which is never retried. Tests for each of the above. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Count connections in the no-delimiter handler and assert exactly one, so the test actually verifies that SseProtocolError bypasses the reconnect path instead of relying on the error being re-raised on a retry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both #100 and this branch appended tests to test_mcp_transport.py; keep both.
Summary
call_tool_streamingfailed for several protocols instead of emitting the full result as a single chunk the way the HTTP protocol does. This PR fixes those and implements the SSE reconnection that the call template already advertised.Streaming fixes
NotImplementedErroron every streaming call. Now runs the command and yields the result once.async forfailed with a TypeError. UDP gets the same shape and return annotation.SSE
reconnect/retry_timeoutwere accepted but never acted on. When an established stream drops, the client now waitsretry_timeoutms (overridden by anyretry:field from the server), reconnects with theLast-Event-IDheader, and gives up afterMAX_RECONNECT_ATTEMPTS(5) per call so a tool call can never hang forever. A clean end of stream completes the call and never reconnects. Connection or HTTP errors on the initial request still fail immediately, so a wrong URL does not trigger retries. The redirect refusal from the security hardening applies to every attempt.Already correct and untouched: file, text, gql, http, streamable_http, websocket.
Test plan
Last-Event-ID;reconnect=Falseraises after partial events; attempt cap is honoredpytestover the cli, socket, mcp and http plugin test suites on top of currentdev: 288 passed, 9 skippedCompanion PR: universal-tool-calling-protocol/typescript-utcp#41 brings the TypeScript implementation to parity.
🤖 Generated with Claude Code
Summary by cubic
Fixes streaming tool calls for CLI, MCP, TCP, and UDP, which previously failed or yielded the wrong object instead of emitting the full result as one chunk. Also implements and hardens SSE reconnection, which the call template advertised but never performed, and pins the MCP plugin to
mcp1.x.Bug Fixes
NotImplementedError.AsyncGeneratorreturn annotation.mcpto<2; version 2.x removedFastMCPandMcpError, which broke test collection in CI.New Features
retry_timeoutms (or the server'sretry:value), reconnects withLast-Event-ID, and stops after 5 attempts.SseProtocolError, which is never retried.Written for commit d86d312. Summary will update on new commits.