Pre-release fixes: reuse the MCP client across calls; error bodies on streaming calls - #104
Conversation
… streaming calls Pre-release review of dev: - mcp: _ensure_mcp_client compared the client's whole config dict with its mcpServers entry, which was always unequal, so every tool call built a new MCPClient and spawned a fresh server process that was never closed. Compare the mcpServers entry, and close the previous client's sessions when the configuration really changes. Test asserts one client and one session across two calls. - sse / streamable_http: the streaming call paths now raise with the server's error body like discovery and the TypeScript SDK already do. - _errors: decode the bounded body once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
5 issues found across 5 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py:296">
P3: This change's whole purpose is surfacing the server's error body on refused streaming calls, but there is no test exercising call_tool_streaming against a 4xx/5xx response. The discovery path has test_register_manual_surfaces_server_error_body, so add an equivalent that hits a 4xx stream endpoint and asserts the raised ClientResponseError message (or call_tool's raised error) contains the server's reason/detail.</violation>
</file>
<file name="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py:114">
P1: When different MCP configurations are used concurrently, this shared client closes the first call's active session before that call finishes, causing in-flight operations to fail. Serialize client replacement with active calls or keep separate clients per configuration instead of closing the shared client during another call.</violation>
<violation number="2" location="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py:114">
P2: The config-change path awaits close_all_sessions and then reassigns self._mcp_client without any lock. The README advertises concurrent calls, so two calls that both observe a config change can both close the old client and both build a new _QuietStdioMCPClient.from_dict(...); the one assigned last survives and the other's freshly spawned server process is never closed - the exact leak this PR is meant to fix. Guard the ensure/create sequence with an asyncio.Lock so only one call rebuilds the client.</violation>
<violation number="3" location="plugins/communication_protocols/mcp/src/utcp_mcp/mcp_communication_protocol.py:115">
P2: When `close_all_sessions()` fails, this handler still replaces the only reference to the previous client, so any session it did not close can leak indefinitely. Preserve the old client for cleanup or fail the reconfiguration instead of discarding it after a shutdown error.</violation>
</file>
<file name="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py:295">
P2: In `call_tool_streaming`, `asyncio.wait_for(..., HANDSHAKE_TIMEOUT_SECONDS)` bounds only `session.request`; the new `raise_for_status_with_body` body read runs outside it. A server that answers the handshake with a 4xx/5xx but never sends the body and keeps the connection open now blocks in `iter_chunked` (bounded only by the session's default 5-min total timeout), where `raise_for_status()` previously raised immediately. Since the handshake is intentionally bounded to prevent a silent server hanging the call, bound the error-body read too — e.g. wrap `raise_for_status_with_body` in `asyncio.wait_for(...)` with a short timeout, or pass a read timeout — so a refused stream still fails fast.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| f"call template to point at the final URL directly." | ||
| ) | ||
| response.raise_for_status() | ||
| await raise_for_status_with_body(response) |
There was a problem hiding this comment.
P3: This change's whole purpose is surfacing the server's error body on refused streaming calls, but there is no test exercising call_tool_streaming against a 4xx/5xx response. The discovery path has test_register_manual_surfaces_server_error_body, so add an equivalent that hits a 4xx stream endpoint and asserts the raised ClientResponseError message (or call_tool's raised error) contains the server's reason/detail.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/streamable_http_communication_protocol.py, line 296:
<comment>This change's whole purpose is surfacing the server's error body on refused streaming calls, but there is no test exercising call_tool_streaming against a 4xx/5xx response. The discovery path has test_register_manual_surfaces_server_error_body, so add an equivalent that hits a 4xx stream endpoint and asserts the raised ClientResponseError message (or call_tool's raised error) contains the server's reason/detail.</comment>
<file context>
@@ -293,7 +293,7 @@ async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str,
f"call template to point at the final URL directly."
)
- response.raise_for_status()
+ await raise_for_status_with_body(response)
async for chunk in self._process_http_stream(response, tool_call_template.chunk_size, tool_call_template.name):
</file context>
There was a problem hiding this comment.
Valid, fixed: tests added for both SSE and Streamable HTTP streaming calls against a 5xx, asserting the body is in the message.
- sse: an event block without an `event:` field has the type "message" (spec), so event_type="message" now matches it. An empty `id:` resets the last event ID and no Last-Event-ID header is sent for it; ids containing NUL are ignored. A 200 whose Content-Type is not text/event-stream raises SseProtocolError instead of parsing into zero events. Calls that send a request body are never reconnected: a re-issued POST could re-execute a non-idempotent tool. - _errors: a body nested deeper than the JSON parser's recursion limit raised RecursionError past the HTTP error handling; it is caught and treated as text. Control characters are collapsed so a server cannot forge log records or terminal escapes through an error message. - mcp: close() referenced a _session_locks attribute that was never assigned and always raised AttributeError after cleanup. A child that starts but fails the MCP handshake is now disconnected and removed from active_sessions instead of lingering. Tests for each. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 6 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/http/src/utcp_http/_errors.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/_errors.py:30">
P2: Escaped C1 controls such as `\u009b` and `\u009c` survive `_clean`, so C1-aware log or terminal consumers can still interpret server-controlled control sequences. Include the C1 range in the sanitizer.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
- sse: `retry:` is honoured only when made of ASCII digits (spec); "-1" or "20ms" no longer change the reconnect delay. A stream that ends in the middle of an event no longer dispatches the incomplete event (spec: pending data is discarded at end of file). - streamable_http: Content-Type is matched case-insensitively. - udp: remove the stale comment block describing the bug this release fixed. - tests: the slow-handshake handler no longer outlives the test by seconds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
3 issues found across 4 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py:397">
P2: When an SSE `retry` field contains more than 4,300 ASCII digits, `int(value)` raises `ValueError` and aborts the stream instead of ignoring the unusable value. Keep the conversion inside a `ValueError` guard.</violation>
<violation number="2" location="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py:435">
P2: A complete, spec-valid event whose closing blank line ends in a lone CR is now dropped at end-of-stream. `normalise` holds a trailing `\r` in `pending_cr` (so a CRLF split across chunks isn't parsed early), and a lone CR is a valid SSE line terminator that can form the event's blank line (e.g. the stream `data: x\n\r`). The removed EOF flush completed it via `if pending_cr: buffer += "\n"` then flushed; the new code unconditionally discards the buffer, so only genuinely incomplete events should be dropped, not CR-terminated ones.</violation>
</file>
<file name="plugins/communication_protocols/http/tests/test_sse_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/tests/test_sse_communication_protocol.py:688">
P3: The test title claims malformed retry is ignored, but the call_template has no reconnect, so the retry parsing result is never observable: the same [{"seq": 1}] passes whether `retry: -1`/`retry: 20ms` is honored or ignored. Only the unterminated-trailing-event behavior is actually verified. Either enable reconnect with an assertion that a server-sent malformed retry does not delay the reconnect (mirroring test_reconnect_delay_is_capped), or rename the test to reflect what it verifies.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
A substring check let a Content-Type such as text/event-stream-invalid through; compare the media-type portion exactly, parameters allowed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- mcp: one MCPClient per distinct server configuration instead of a single shared client. The protocol object is registered once per process and shared by every manual, so a single client made manuals with different configurations evict each other's sessions, including ones still in use by a concurrent call. Clients are keyed by the canonical configuration, created under a lock so two concurrent first calls cannot each spawn a server, and never evicted by another manual's activity. close() closes every client's sessions and keeps a client whose shutdown failed so a later close() can retry it. - sse: the error-body read on a refused stream is bounded by the handshake timeout, so a server that answers 4xx/5xx and then stalls cannot hang the call. A final blank line ending in a lone CR still completes the last event; only genuinely incomplete events are discarded. Absurdly long retry digit strings are ignored. - _errors: control-character collapsing covers the C1 range too. - tests: streaming calls against a 5xx assert the body is surfaced for both SSE and Streamable HTTP; separate-clients-per-configuration test; the malformed-retry test is named for what it verifies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 6 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py:405">
P2: On Python 3.10, `int(value)` accepts arbitrarily long digit strings, so the new `ValueError` guard does not bound this conversion. A server can send a delimiter-terminated `retry:` field containing up to the 16 MiB event limit and block the event loop before the later reconnect-delay cap runs. Bound the digit length before calling `int()` (values above the effective delay cap can be ignored or treated as capped).</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| # int() refuses absurdly long digit strings; ignore those too. | ||
| if value.isascii() and value.isdigit(): | ||
| try: | ||
| current_event['retry'] = int(value) |
There was a problem hiding this comment.
P2: On Python 3.10, int(value) accepts arbitrarily long digit strings, so the new ValueError guard does not bound this conversion. A server can send a delimiter-terminated retry: field containing up to the 16 MiB event limit and block the event loop before the later reconnect-delay cap runs. Bound the digit length before calling int() (values above the effective delay cap can be ignored or treated as capped).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/src/utcp_http/sse_communication_protocol.py, line 405:
<comment>On Python 3.10, `int(value)` accepts arbitrarily long digit strings, so the new `ValueError` guard does not bound this conversion. A server can send a delimiter-terminated `retry:` field containing up to the 16 MiB event limit and block the event loop before the later reconnect-delay cap runs. Bound the digit length before calling `int()` (values above the effective delay cap can be ignored or treated as capped).</comment>
<file context>
@@ -397,8 +399,12 @@ def flush(event_string: str):
if value.isascii() and value.isdigit():
- current_event['retry'] = int(value)
+ try:
+ current_event['retry'] = int(value)
+ except ValueError:
+ pass
</file context>
| current_event['retry'] = int(value) | |
| if len(value) <= len(str(self.MAX_RECONNECT_DELAY_MS)): | |
| current_event['retry'] = int(value) |
There was a problem hiding this comment.
Valid, fixed: the digit string is bounded to 18 characters before conversion, which keeps it cheap on any interpreter and still covers every value the delay cap could honour.
- mcp: when a manual's configuration changes and no other manual uses the old one, the old client's sessions are closed instead of lingering until close(). Ownership is tracked per manual name. - sse: the residual buffer is checked against the event cap at end of stream too; retry digit strings are bounded to 18 digits before conversion. - tests: the connection-drop handlers wait 100 ms after writing the first event before closing the socket. On Windows CI the event and the close otherwise arrived together and aiohttp raised before delivering the event, failing six tests that pass elsewhere. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 6 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="plugins/communication_protocols/http/tests/test_streamable_http_communication_protocol.py">
<violation number="1" location="plugins/communication_protocols/http/tests/test_streamable_http_communication_protocol.py:370">
P2: This test cannot detect the fix it claims to verify. The /error endpoint body ("Internal Server Error") equals the standard HTTP reason phrase for status 500, so `excinfo.value.message` contains that string whether or not the server body is surfaced: plain raise_for_status() yields message "Internal Server Error" and raise_for_status_with_body yields "Internal Server Error: Internal Server Error". Make the body distinct from the reason (e.g. return `web.Response(status=500, text="streaming refused: backend down")`) and assert on that distinct string, so the test actually proves the streaming path surfaces the body.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| async for _ in streamable_http_transport.call_tool_streaming(None, "test-provider.t", {}, call_template): | ||
| pass | ||
| assert excinfo.value.status == 500 | ||
| assert "Internal Server Error" in excinfo.value.message |
There was a problem hiding this comment.
P2: This test cannot detect the fix it claims to verify. The /error endpoint body ("Internal Server Error") equals the standard HTTP reason phrase for status 500, so excinfo.value.message contains that string whether or not the server body is surfaced: plain raise_for_status() yields message "Internal Server Error" and raise_for_status_with_body yields "Internal Server Error: Internal Server Error". Make the body distinct from the reason (e.g. return web.Response(status=500, text="streaming refused: backend down")) and assert on that distinct string, so the test actually proves the streaming path surfaces the body.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/http/tests/test_streamable_http_communication_protocol.py, line 370:
<comment>This test cannot detect the fix it claims to verify. The /error endpoint body ("Internal Server Error") equals the standard HTTP reason phrase for status 500, so `excinfo.value.message` contains that string whether or not the server body is surfaced: plain raise_for_status() yields message "Internal Server Error" and raise_for_status_with_body yields "Internal Server Error: Internal Server Error". Make the body distinct from the reason (e.g. return `web.Response(status=500, text="streaming refused: backend down")`) and assert on that distinct string, so the test actually proves the streaming path surfaces the body.</comment>
<file context>
@@ -356,3 +356,15 @@ async def test_register_manual_surfaces_server_error_body(streamable_http_transp
+ async for _ in streamable_http_transport.call_tool_streaming(None, "test-provider.t", {}, call_template):
+ pass
+ assert excinfo.value.status == 500
+ assert "Internal Server Error" in excinfo.value.message
</file context>
There was a problem hiding this comment.
Valid, fixed: both streaming error-body tests now hit a 503 whose body is distinct from the reason phrase and assert on that string.
- mcp: deregistering a manual drops only that manual's claim on its client; the client's sessions are closed when no manual references the configuration any more. Two manuals with identical configurations share one client, and deregistering one no longer tears down the other's sessions or leaves a ghost ownership entry. - tests: the streaming error-body tests use a body distinct from the reason phrase so they can only pass when the body is surfaced; deregistration test for shared configurations. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…me alone The protocol object is a process-wide singleton, so two UtcpClient instances may register a manual of the same name with different configurations; keyed by name alone, the second registration overwrote the first's ownership entry and closed its client. The calling client's identity is now part of the owner key, carried through the public entry points with a context variable rather than threading `caller` through every helper. Test added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Restoring a failed client into the live map happened outside the lock and could overwrite a newer client for the same configuration. Failed clients now go to a separate list that close() retries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Follow-ups from a pre-release review of
dev(#103), done before merging tomain. Two batches.Batch 1
_ensure_mcp_clientcompared the client's whole config dict with just themcpServersmapping, which is always unequal, so every tool call created a newMCPClientand spawned a fresh server process that was never closed. Fixed; when the configuration really changes the previous client's sessions are closed first. Test asserts one client and one session across two calls.raise_for_status_with_body, like their discovery paths.Batch 2
event:field has the typemessage, soevent_type="message"now matches it; an emptyid:resets the last event ID and noLast-Event-IDheader is sent for it; ids containing NUL are ignored; a 200 that is nottext/event-streamraisesSseProtocolErrorinstead of parsing into zero events._errorsrobustness: a body nested deeper than the JSON parser's recursion limit escaped asRecursionError; it is now treated as text. Control characters are collapsed so server text cannot forge log records or terminal escapes.close()referenced a_session_locksattribute that was never assigned and always raisedAttributeErrorafter cleaning up. A child that starts but fails the MCP handshake is now disconnected and removed fromactive_sessionsinstead of lingering.Batch 3 (cubic's findings on #103 that were not already covered)
retry:is honoured only when made of ASCII digits; a stream that ends mid-event no longer dispatches the incomplete event (both per spec).Batch 4 (cubic's findings on #104)
mcpServersJSON, created under a lock, and never evicted by another manual's activity.close()keeps a client whose shutdown failed so it can be retried.retrydigit strings are ignored; the sanitizer covers C1 controls.Batches 5 to 7 (cubic's findings on this PR)
UtcpClientplus the manual name, since the protocol object is a process-wide singleton.retrydigit strings are bounded before conversion.Behavior changes worth noting for the release notes:
ClientResponseError.messageon failed HTTP calls is now<reason>: <detail>rather than the bare reason phrase;structuredContenthandling in MCP changed (a tool without structured output no longer returnsNone, and a single-key{"result": {...}}object is no longer unwrapped).Test plan
pytestover the http plugin tests and the MCP stdio tests: 244 passed, then 251 across http and MCP stdio after batch 6🤖 Generated with Claude Code