fix(tools): arm the MCP circuit breaker on transport failures only - #88368
fix(tools): arm the MCP circuit breaker on transport failures only#88368Adridot wants to merge 1 commit into
Conversation
Duplicate of #61555: both ensure completed MCP RPCs reset the transport circuit breaker even when the tool returns an application-level error. |
The breaker counted any error-shaped tool result as a consecutive server failure, so a reachable server that merely rejected the model's arguments got declared unreachable for the whole 60s cooldown. Three such rejections are trivially reachable inside a single parallel tool-call batch, and every call for the next minute then returned "MCP server 'x' is unreachable after 3 consecutive failures" about a server that was answering fine. `_call` already asserts the correct invariant about 100 lines above, where it calls `_mark_session_proven()` because "the RPC round-trip completed — the session is demonstrably healthy at the transport level (even if the tool itself returned isError)". The breaker logic below then contradicted it. Reset on any returned result instead: a completed round-trip is proof of reachability. This also stops replacing the server's own error message — which often names the correct tool to use instead — with a false availability diagnostic. Transport and session failures still arm the breaker: the not-connected and dead-session guards above, and the except branches below, are untouched. Repeated *tool* failures remain the per-turn loop guardrails' concern.
d34c8c0 to
75f3de0
Compare
|
Closing in favour of #61555, which is a month older (2026-07-09) and makes the same change. I rebased this branch onto current I have added our production reproduction to #61555 as corroboration rather than restating it here. Reviewers looking for this defect should go there. |
What does this PR do?
The MCP circuit breaker counts any error-shaped tool result as a "consecutive failure", so a healthy, answering server gets declared unreachable when the model sends bad arguments a few times:
With
_CIRCUIT_BREAKER_THRESHOLD = 3and a 60 s cooldown, three rejected calls — trivially reachable inside one parallel tool-call batch — black out the server for a minute and every subsequent call getsMCP server 'x' is unreachable after 3 consecutive failures, which is simply false.The codebase already states the correct invariant, about 100 lines above, and acts on it:
So
_callmarks the session proven on an isError result, and then the breaker logic below contradicts it by counting that same result as a strike. This PR makes the breaker consistent with the invariant already asserted upstream of it: a returned result is proof of reachability. Net effect on production code is a deletion — the JSON sniffing goes away.Separation of concerns after this change:
Repeated tool failures already have an owner, and it isn't the breaker.
Real trace
Production cron run. The server answers four calls in the same batch — one of them 49 KB — then rejects three on argument validation, and is declared unreachable:
Note the shape of the failure: the server itself told the caller which tool to use instead (
use search_count), and that useful message is what gets replaced by a bogus availability diagnostic on every following call for a minute.Related Issue
Prior art, stated openly — this is a known defect with existing work, and I'd rather name it than have a reviewer discover it:
None of those four has a review at the time of writing, the oldest has been open since 2026-07-21, and the defect is still present on
main. I'm opening this rather than piling onto one of them because it takes a different and smaller route — deriving the fix from the_mark_session_proven()invariant already in the file, which turns the change into a deletion instead of new classification logic — and because it comes with a reproducible production trace. If a maintainer prefers any of the existing PRs, close this one; the goal is the fix landing, not this diff specifically. I'm equally happy to fold these tests into whichever PR you'd rather take.Complementary, not overlapping: #88357 fixes the loop guardrail counting one parallel batch as N retries. In the incident above both defects had to line up — the breaker blacked out a healthy server, then the halt guardrail counted the blocked batch as 8 retries and ended the turn. Either fix alone would have prevented it.
Type of Change
Changes Made
tools/mcp_tool.py— in_make_tool_handler's success path, drop the payload sniffing and reset the breaker whenever_call_once()returns. Transport and session failures keep arming it exactly as before: the not-connected and dead-session guards above, and theexceptbranches below, are untouched.tests/tools/test_mcp_circuit_breaker.py— 3 tests (below), reusing the file's existing stub-server harness.No config keys, no schema changes, no public API changes. Cross-platform: in-memory counters only.
How to Test
Reproduce on
main— a server that only ever rejects arguments:On
mainthis fails withAssertionError: {'error': "MCP server 'srv' is unreachable after 3 consecutive failures…"}— the healthy stub server got blacklisted. With this PR it passes.Full file:
test_tool_level_error_does_not_arm_breakerthreshold + 2isError replies never trip the breaker; every call still reaches the session; the server's own message survivestest_transport_exception_still_arms_breakertest_answered_call_closes_breaker_even_when_tool_errored2 of the 3 fail on
main;test_transport_exception_still_arms_breakerpasses both before and after by design — it pins the behavior this PR must not change.Checklist
Code
fix(tools):)main)Test scope.
tests/tools/test_mcp_circuit_breaker.py→ 10 passed (7 pre-existing + 3 new). The whole MCP blast radius — everytests/tools/test matchingmcp— was also run and compared against the merge base (b52b725f62) to separate my effects from my environment's:b52b725f62The failure sets are identical (set difference empty in both directions), and the
+3passed are exactly the new tests. The 6 pre-existing failures aremcpSDK version skew in my environment —cannot import name 'MCPError' from 'mcp.shared.exceptions', two OAuth callback-port tests hitting'tuple' object has no attribute 'code', and an elicitation pydantic mismatch — none of which touch_make_tool_handler.ruff check,git diff --checkandscripts/check-windows-footguns.pyare clean on the changed files. I did not run the entiretests/tree locally; CI is authoritative there.I also grepped the suite for tests asserting the old behavior (a tool-level error incrementing
_server_error_counts) and found none, so no existing test needed rewriting to accommodate this change.Documentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs
What the model saw after the breaker armed — eight instant short-circuits, none of which touched the server:
The server was healthy throughout: an unrelated scheduled job hit the same server 30 minutes later and completed normally.