fix: retry an MCP server's first connect attempt before giving up (#610) - #650
Conversation
McpConnectionManager.CreateClientAsync had no retry on a server's first connect attempt -- only an already-connected client that later went stale got a retry (ReconnectAsync/RetryAfterReconnectAsync). A single slow cold-start (npx still installing, a container still starting) on the one server a plugin's DeniedTools entry depends on could permanently deny that plugin's entire tool surface for the life of the host, since PluginBoundaryStatus.Faulted has no un-fault path by design. Retries the whole connect (fresh transport per attempt, not a reused one) up to 3 total attempts with a 1s delay between. Deliberately a smaller budget than PluginToolBoundaryStartupValidator's existing 5-attempt availability-probe loop, which wraps this same method for a different reason and would otherwise multiply into a much longer worst case. A McpConnectionException from transport construction (missing URL, blocked host) is a deterministic error and is not retried. Verified empirically against the pinned SDK (ModelContextProtocol 1.4.1) that a failed StdioClientTransport connect -- both a fast process-exit and a genuine handshake timeout -- leaves no lingering child process, so a fresh transport per retry attempt is safe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
Round-2 /code-review (5-angle background pass) found several real issues in the initial retry fix, all verified empirically and fixed: - Removed PluginToolBoundaryStartupValidator's now-redundant 5-attempt availability-probe loop, which wrapped the new 3-attempt connect retry with no shared budget, multiplying worst-case attempts to 15 and wall-clock time ~3x. The outer loop existed only to compensate for the connect layer having no retry of its own -- now that it does, a single direct GetToolsAsync call is correct. - Retry is now scoped to a genuine first connect only. ReconnectAsync (a live agent turn recovering an already-established, now-stale session) keeps its original single-attempt latency -- applying the same retry budget there would have tripled worst-case turn latency for a scenario #610 was never about. - A genuine caller cancellation landing on the retry loop's LAST attempt was silently wrapped into McpConnectionException instead of propagating as a cancellation (no subsequent Task.Delay call to re-surface it, unlike earlier attempts) -- verified this could spuriously record a permanent PluginBoundaryStatus.Faulted for a server that was never actually unreachable. - An empty/missing Stdio Command threw a raw ArgumentException from the SDK's own transport options setter, which the retry filter treated as transient and retried -- burning ~2s on a deterministic config error the HTTP path already failed fast on. Now validated explicitly, matching the HTTP "missing URL" check. - Extracted the retry loop into its own method (CreateClientAsync had grown to 68 lines, over this repo's 50-line function limit). - Added jitter to the retry delay so multiple cold-starting servers don't retry in exact lockstep. - A bundle-owned connection is now never retried, regardless of caller intent: every bundle-owned HTTP/SSE request is individually audited (EgressPolicyDelegatingHandler), and both an egress-policy deny and an AntiSSRF block are deterministic security verdicts against a fixed target, not a transient cold start -- retrying was writing duplicate audit entries for one auditable decision (caught by BundleMcpEgressAttributionTests). Every fix mutation-tested. Updated PluginToolBoundaryStartupValidatorTests to match the simplified direct-call contract. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
…arks (#610) run-gates.sh's grader gate flagged ConnectWithRetryAsync at 62 lines, over this repo's 50-line function guideline -- the excess was almost entirely a comment block explaining why McpConnectionException and OperationCanceledException are excluded from the retry filter. Moved that explanation into the method's XML <remarks> (which IDEs surface identically on hover) and left a one-line pointer in the code, bringing the method body to 48 lines with no logic change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu
🔒 Security review — PR #650Bottom line: PASS — no HIGH findings. The change adds a bounded retry (3 attempts, 1s + ≤250ms jitter) to a first MCP connect and removes the now-redundant outer availability-probe loop in What I verified (no finding)
MEDIUM / LOW — advisory, not blockingLOW · LOW · LOW · Reviewed: |
Correctness review — PR #650Bottom line: Blocking defectsNone. What I verified (so the PASS is legible)
Advisory
|
Summary
Closes #610.
McpConnectionManager.CreateClientAsynchad no retry on a server's first connectattempt — only an already-connected client that later went stale got a retry. A single slow
cold-start (
npxstill installing, a container still starting) on the one server a plugin'sDeniedToolsentry depends on could permanently deny that plugin's entire tool surface for thelife of the host, since
PluginBoundaryStatus.Faultedhas no un-fault path by design.Retries the whole connect (fresh transport per attempt) up to 3 times with a jittered 1s delay,
scoped to a genuine first connect only.
Review history — round 2 found 8 real bugs in the fix itself
A 5-angle background
/code-reviewpass on the initial fix found, and I fixed (each verifiedempirically against the pinned SDK and mutation-tested):
PluginToolBoundaryStartupValidator's own 5-attempt availabilityprobe wrapped this method too, multiplying worst-case attempts to 15 and wall-clock to ~3x.
That outer loop existed only to compensate for the connect layer having no retry of its own —
removed entirely rather than tuned around.
ReconnectAsync(a liveagent turn recovering an already-established, stale session) would have tripled worst-case turn
latency for a scenario Plugin boundary: initial MCP connect has no retry, so a slow cold-start permanently Faults the plugin #610 was never about. Scoped retry to first-connect only.
retry loop's last attempt was silently wrapped into
McpConnectionException(no subsequentdelay call to re-surface it, unlike earlier attempts) — could spuriously record a permanent
Faultedstatus for a server that was never actually unreachable.Commandthrows a rawArgumentExceptionfrom the SDK's own options setter, which the retry filter treated astransient. Now validated explicitly, matching the HTTP "missing URL" check.
request; retrying a deterministic security verdict (an egress-policy deny or an AntiSSRF block
against a fixed target) wrote duplicate audit entries for one decision. Bundle-owned connections
are now never retried, regardless of caller intent.
XML remarks after the fix above still left the method a few lines over the guideline.
Test plan
dotnet build src/AgenticHarness.slnxInfrastructure.AI.MCP.Tests: 156/156 passInfrastructure.AI.Tests(full suite): 3523/3523 pass (one known pre-existing flakysandbox-process test confirmed clean in isolation)
ModelContextProtocol1.4.1) via throwawayconsole apps — process/transport disposal on a failed connect, cancellation exception shape,
the Stdio
ArgumentExceptionshape — not asserted from documentation alonerun-gates.shgrader gate: clean pass ("LOOKS GOOD", one non-blocking function-length note,since fixed). Local full-suite
run-gates.shrepeatedly killed by genuine host memorypressure (not test failures) — pushed via the sanctioned
RAILS_SKIP_REVIEW_GATE=1bypass,deferring to CI's non-contended run.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Aao7Y3hU22v6VH1RdiSxYu