Skip to content

fix: mcp robustness fixes under high-concurrency#2062

Merged
mikasenghaas merged 16 commits into
mainfrom
fix/mcp-transport-resilience
Jul 18, 2026
Merged

fix: mcp robustness fixes under high-concurrency#2062
mikasenghaas merged 16 commits into
mainfrom
fix/mcp-transport-resilience

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Jul 18, 2026

Copy link
Copy Markdown
Member

Companion re-pin: PrimeIntellect-ai/prime-rl#3084

Summary

Make the v1 MCP client paths resilient to connection churn under high rollout concurrency, so per-rollout transport blips no longer fail rollouts — without replay-corrupting the user simulator.

Under high concurrency (e.g. 512 rollouts on the subprocess runtime), the user-sim and tool MCP connections drop at rollout-start churn (httpx.ReadErroranyio.ClosedResourceError). Previously:

  • the host-side user path (connect_user) held one connection open for the whole rollout and retried only the initial connect — a mid-session respond() drop propagated. The opening respond("") (interception handle_request) was unwrapped, so a drop there became a 500 to the harness, which exited non-zero → HarnessError → failed rollout. Worse, the harness SDK's retry of that 500 made the interception re-invoke respond("") against a simulator that had already advanced for the lost turn — silently corrupting the trajectory (observed as empty/garbage turns upstream).
  • the harness tool path (null/bash program.py) held persistent MCP sessions with no retry, so a mid-loop call_tool transport drop crashed the harness → failed rollout.

Design

Every call now runs on a fresh session (the servers are stateless-HTTP: setup()/setup_task() run once at startup, per-rollout state lives on the shared-state channel), and retries are made safe rather than avoided:

  • User turns are exactly-once via a server-side replay cache. The wire respond tool gains a seq conversation position (derived by the interception from the prompt position — byte-identical across SDK retries). The per-rollout user server keeps its last (seq, message) → payload and replays a retried turn instead of advancing the simulator twice; a lock serializes duplicate in-flight attempts (a retry racing a slow first execution) so they join the recorded turn. This dedups the host client's retries and the interception-level re-invocations. The host retries the full call through the existing shared retrying() policy (verifiers.v1.retries).
  • A retried opening request injects the cached opening. The opening-injection guard keyed on trace.num_turns == 0, which mutates across attempts: an SDK retry of the opening request after the simulated conversation had committed turns (final response lost) skipped injection and forwarded its empty message list upstream (the renderer's deterministic No messages provided 502). The opening request is now recognized by its own shape — no assistant turn in the request — which is stable across retries.
  • Task tool calls are at-least-once, documented. MCP has no idempotency key and tools are arbitrary code, so with_retry in the harness programs replays a call whose response was lost; a tool that fails for real reports through its result, not an exception, so a retried exception is a transport fault. (At-most-once was tried and measurably regressed: 0.4–4.2% of rollouts per step failed at 512 concurrency because drops hit the round-trip itself, not just setup.)
  • Teardown failures after a completed body are suppressed — the result is already in hand; closing noise must not fail (or replay) an already-answered call.
  • Response parsing/conversion happens outside the retry — a client-side parse failure fails once, never re-invokes the call.

Each session is opened and closed within the caller's task, keeping AnyIO cancellation scopes correctly nested; cancellation is never retried or swallowed. The program.py scripts (deps openai/mcp/httpx, can't import verifiers) carry a small inline with_retry; the null program now declares its direct httpx dependency.

Verification

Semantics (real code, stubbed transports + a real stateless FastMCP server):

  • setup churn → retried to success, call runs once
  • mid-call drop → replayed with the same seq; the user server's replay cache returns the recorded turn (simulator advances once)
  • replay cache: retried (seq, message) → cached payload, simulator invoked once; new seq → advances; seq=-1 → no dedup
  • teardown failure after a completed call → swallowed, result usable
  • exhaustion raises; cancellation propagates immediately, unretried
  • unparseable respond payload → UserError raised once, respond invoked exactly once

End-to-end, 512 concurrency, subprocess runtime, one env per MCP shape:

Env Shape Rollout error before after
alphabet-sort-v1 user simulator 15.1% 0.0% (5/5 steps)
gsm8k-v1 vanilla (no MCP) 0.0% 0.0%
wiki-search-v1 tool (shared, fan-in) 0.0%

alphabet-sort-v1 is the reported regression: before the fix, the opening user-sim respond 500'd under churn (ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)HarnessError('harness 'null' exited 1: ... openai.InternalServerError: 500')). In the final 5-step run: 0 rollout failures, 0 empty-message upstream calls, and 20 respond retries — every one absorbed by the replay cache (the churn still happens; it's now handled). For calibration, the at-most-once alternative (retry setup only, never the call) was measured on the same setup and failed 0.4–4.2% of rollouts per step.


Note

Medium Risk
Changes rollout-critical MCP and user-simulator semantics (retries, at-least-once tools, seq-based dedup); behavior is well-tested at 512 concurrency but any custom MCP tool that is not replay-safe could see duplicate side effects.

Overview
Hardens v1 MCP paths so transport churn at high rollout concurrency no longer fails harnesses or double-advances user simulators.

Harness programs (null/bash program.py) drop long-lived MCP sessions: connect_mcp only enumerates tools and returns server specs; each list_tools / call_tool opens a fresh streamable-HTTP session with httpx timeouts, tenacity backoff (6 attempts), and teardown errors suppressed after a successful body. Tool calls are documented as at-least-once on replay.

Host user path replaces connect_user with user_respond + user_session (per-call session, shared retrying()). serve_user yields partial(user_respond, url). The MCP respond tool gains a seq argument; the user server caches (seq, message) → payload under an asyncio.Lock so retries replay instead of advancing twice.

Interception passes len(prompt) as seq for opening and follow-up user turns. Opening injection no longer keys on trace.num_turns == 0 (unstable on SDK retries); it triggers when the request has no assistant messages. Prompt assembly after a model turn appends the assistant message before calling the user sim, then only the user messages (avoiding duplicate assistant in prompt).

Reviewed by Cursor Bugbot for commit 7d9adb2. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix MCP robustness under high concurrency with per-call sessions, retries, and timeouts

  • Replaces long-lived MCP sessions with per-call sessions in both the bash and null harnesses, so a stale or broken connection no longer blocks the entire run.
  • Adds with_retry (up to 6 attempts, exponential backoff) and explicit httpx.Timeout (60s connect / 300s read) to all MCP tool enumeration and invocation paths.
  • Adds a sequence number (seq) to the user simulator's respond MCP tool, allowing the server to deduplicate retried requests via an asyncio.Lock and a (seq, message) replay cache.
  • Updates serve_user in launch.py to yield a partial(user_respond, url) instead of maintaining a persistent connection, aligning with the new per-call model.
  • Behavioral Change: connect_mcp no longer accepts an AsyncExitStack and now returns (tool_schemas, dispatch, servers); callers must pass servers and dispatch to call_mcp.

Changes since #2062 opened

  • Modified verifiers.v1.mcp.user.User._register method to ensure atomic state synchronization and response generation under a single lock [7d9adb2]
  • Replaced manual retry logic with tenacity library in with_retry utility across verifiers.v1.harnesses.bash.program and verifiers.v1.harnesses.null.program modules [7d9adb2]
  • Updated MCP_TIMEOUT configuration to use httpx.Timeout with explicit connect timeout in verifiers.v1.harnesses.bash.program and verifiers.v1.harnesses.null.program modules [7d9adb2]

Macroscope summarized 9674b53.

…rn doesn't fail rollouts

Under high rollout concurrency (e.g. 512 on the subprocess runtime) the user-sim
and tool MCP connections drop (httpx.ReadError -> anyio.ClosedResourceError) at
rollout-start churn. Previously the host-side user path held one connection open
for the whole rollout and retried only the initial connect, and the harness tool
path held persistent sessions with no retry — so a single transient drop 500'd
the interception opening turn (crashing the null harness) or crashed the harness
mid-loop, failing the rollout.

The MCP servers are stateless-HTTP (setup runs once at startup; state lives on the
shared-state channel), so they hold nothing per connection. Reconnect on a fresh
session per call and retry transient transport drops with backoff:

- launch.py: connect_user yields a respond that opens/closes a session per turn
  (_user_respond) with bounded retry; the opening respond("") is idempotent.
- null/default program.py: connect_mcp enumerates tools without holding sessions;
  call_mcp opens a fresh session per call and retries transient drops. main() no
  longer holds an AsyncExitStack of live connections.

Each session is opened and closed within the caller's task, keeping AnyIO cancel
scopes nested. Validated e2e at 512 concurrency / 1 step on the subprocess runtime:
alphabet-sort (user) 15.1% -> 0.0% rollout error, gsm8k (vanilla) 0.0%, wiki-search
(tool) 0.0%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread verifiers/v1/harnesses/null/program.py Outdated
…; drop leading _ from MCP constants

The host-side user-sim path now drives its per-call reconnect through the existing
verifiers.v1.retries.retrying() policy (tenacity: exponential backoff + jitter +
logging) — the same one open_tunnel uses for "targeted retries where there's no SDK
underneath" — instead of a hand-rolled loop. TRANSIENT_ERRORS lists the transport
faults to retry (incl. the task group's ExceptionGroup, which at this boundary only
wraps a transport failure); non-transport body errors are not retried.

The harness program.py scripts can't import verifiers and keep deps minimal
(openai/mcp), so they keep a small inline retry with the same semantics.

Also renames the module-level MCP tunables to public (no leading underscore):
MCP_CALL_RETRIES / MCP_TIMEOUT / TRANSIENT_ERRORS (launch) and
MCP_CALL_ATTEMPTS / MCP_CALL_BACKOFF / MCP_CALL_MAX_BACKOFF / MCP_TIMEOUT (programs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread verifiers/v1/mcp/launch.py Outdated
mikasenghaas and others added 5 commits July 18, 2026 05:50
Drops the leading underscore (parity with the now-public MCP_* / TRANSIENT_ERRORS
constants). is_transient stays a small inline helper in each standalone program.py
(they can't import verifiers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… whitelist

Drops is_transient / TRANSIENT_ERRORS. Retrying any Exception is sound at this
boundary: a tool that fails for real returns an error in its result rather than
raising, and cancellation (CancelledError, or an ExceptionGroup carrying one, which
promotes to BaseExceptionGroup) is a BaseException — so tenacity's default
on=Exception (and the harness's bare 'except Exception') never retry it. The host
path keeps give_up=RolloutError so our own deterministic boundary errors aren't
retried. Removes the now-unused anyio imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mikasenghaas
mikasenghaas marked this pull request as ready for review July 18, 2026 06:31
@mikasenghaas
mikasenghaas requested a review from xeophon July 18, 2026 06:31
Comment thread verifiers/v1/mcp/launch.py
@macroscopeapp

macroscopeapp Bot commented Jul 18, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

This PR makes significant changes to MCP session management and retry logic. An unresolved P1 review comment identifies a race condition bug in the replay cache logic where the cache can be visible before state commits, contradicting the PR's concurrency robustness goals.

You can customize Macroscope's approvability policy. Learn more.

The retry scope wrapped call_tool AND the client-side parsing (json.loads /
parse_message on the user path, mcp_content_to_chat_content on the tool path). If
call_tool already committed on the server, a parse failure re-invoked the whole
call — advancing the user simulator or repeating tool side effects. Retry only the
call; parse the returned result outside the retry, so a parse failure fails once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread verifiers/v1/mcp/launch.py Outdated
mikasenghaas and others added 2 commits July 18, 2026 06:52
Addresses the review findings on the retry scope:
- retrying call_tool is at-least-once delivery: a connection lost after the
  server executed the call but before the response arrived would replay it,
  double-advancing the user simulator or repeating tool side effects.
- an exception during session TEARDOWN (after a successful call) escaped the
  old retry scope's `async with` and also triggered a replay.

The session context manager now owns resilience: setup (open transport +
initialize) is retried freely since nothing has reached the tool yet; the body
runs at most once (a mid-call drop fails the rollout instead of risking a
duplicate side effect); a teardown failure after a completed body is swallowed
since the result is already in hand. Call sites collapse to a plain
`async with mcp_session(...)`.

launch.py drives the setup retry through the shared retrying() policy; the
standalone harness programs keep an inline loop. Also declares the null
program's direct httpx import in its script dependencies.

Verified against stubbed transports: setup retried to success, mid-call drop
raises once (call count 1), teardown-after-body swallowed, setup exhaustion
raises, cancellation propagates unretried; plus real-server round-trip and
parse-failure-fails-once tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
At-most-once (retry only session setup, never the call) measurably regressed:
at 512 concurrency, transport drops hit the respond round-trip itself, failing
0.4-4.2% of rollouts per step. And the double-advance hazard already existed a
layer up: when the opening respond's response was lost, the harness SDK retried
the 500 and the interception re-invoked session.user("") against a simulator
that had already advanced - producing corrupt turns (empty messages upstream).

Retry the call freely and make the replay safe instead:
- User.respond (wire tool) gains a `seq` conversation position; the per-rollout
  user server keeps its last (seq, message) -> payload and replays a retried
  turn instead of advancing the simulator twice. This dedups the host client's
  retries AND the interception-level re-invocations (both pass the same seq -
  the interception derives it from the prompt position, which is byte-identical
  across SDK retries).
- launch.py: _user_session is a plain fresh-session CM that suppresses teardown
  failures after the body (the result is in hand); _user_respond retries the
  full call via the shared retrying() policy with a stable seq; the payload is
  parsed outside the retry so a parse failure fails once.
- null/bash program.py: same plain session CM + a single with_retry helper for
  tool enumeration and calls. Task tool calls are documented at-least-once (MCP
  has no idempotency key); a failed tool reports through its result, not an
  exception, so retried exceptions are transport faults.

Verified: 12 stubbed-transport semantics tests (setup churn retried, mid-call
drop replayed with the same seq, teardown-after-body swallowed, exhaustion
raises, cancellation unretried, replay cache dedups retried turns and advances
on new ones) plus real-server round-trip and parse-fails-once tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread verifiers/v1/mcp/user.py
mikasenghaas and others added 2 commits July 18, 2026 07:17
The replay cache is written after the simulator runs, so a retry racing a slow
first execution (churn-delayed server) missed it - each duplicate advanced the
simulator, and a burst could exhaust a queued-turns sim entirely (observed as
empty openings -> "No messages provided" upstream, ~0.2%/step at 512). A lock
around the wrapper serializes duplicates: they wait, re-check the cache, and
join the recorded turn instead of re-executing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opening-injection guard keyed on `trace.num_turns == 0`, which mutates
across attempts: when the opening request's final response is lost after the
simulated conversation already committed turns, the SDK's byte-identical retry
arrived with num_turns > 0, skipped the injection, and forwarded its empty
message list upstream — the renderer's "No messages provided" 502, repeated
deterministically until the harness died (the residual ~0.2-0.4%/step at 512
concurrency). Recognize the opening request by its own shape instead — no
assistant turn in the request — which is stable across retries; later requests
in tool loops carry assistant history and are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit eaa53e4. Configure here.

Comment thread verifiers/v1/mcp/user.py Outdated
The user-turn position was taken before the assistant message joined the
prompt, so when a simulator opened with zero messages and the model's first
non-tool reply was empty, the first post-model turn shared the opening's
(seq, message) key and replayed the opening payload instead of advancing the
simulator. Append the assistant turn to the prompt before taking the position,
making every mid-conversation seq strictly greater than the opening's.

Also documents that the respond replay cache spans exactly one conversation -
the user server process is launched per rollout (serve_user), so the cache
cannot leak across rollouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mikasenghaas mikasenghaas changed the title fix: reconnect-per-call for user/tool MCP so high-concurrency churn doesn't fail rollouts fix: mcp robustness fixes under high-concurrency Jul 18, 2026
…n plumbing

- MCP_TIMEOUT mirrors the OpenAI SDK client defaults (connect=5s, everything
  else 600s): a connect failure under churn surfaces in seconds and hits the
  retry machinery instead of hanging for a minute, and one familiar default
  replaces a bespoke pair.
- Inline the connect_user wrapper (it existed to hold the old persistent
  session); serve_user yields partial(user_respond, url) directly.
- user_respond/user_session lose their leading underscore; the replay cache
  unpacks into (last_turn, last_payload) instead of a 3-tuple.
- Trim comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread verifiers/v1/mcp/user.py
mikasenghaas and others added 2 commits July 18, 2026 16:43
The lock + replay cache lived inside `_with_state(respond)`, so `_push_state`
ran in the wrapper after `respond` returned — outside the lock. A racing retry
could take the lock, hit the cache, and return the recorded payload before the
original attempt's state commit landed: the interception could then evaluate
`@stop` against stale state (or see a success whose state never committed).

Invert the nesting: `_with_state` now wraps an inner `advance`, and the lock +
cache wrap that. The whole turn — pull, advance, commit — runs under the lock,
and the cache is published only after it commits, so a racing retry either
drives a fresh turn or joins the fully-committed one. Reported by xeophon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hand-rolled retry loop in the null/bash harness programs with
tenacity's AsyncRetrying (exponential backoff + jitter), matching the shared
`retrying()` policy the host side already uses. Roughly LOC-neutral, but a
battle-tested backoff instead of a bespoke one. Adds tenacity (pure-python,
zero-dep) to each program's PEP-723 dependencies; drops the now-unused
MCP_CALL_BACKOFF / MCP_CALL_MAX_BACKOFF constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mikasenghaas
mikasenghaas merged commit 4a3c1eb into main Jul 18, 2026
12 checks passed
@mikasenghaas
mikasenghaas deleted the fix/mcp-transport-resilience branch July 18, 2026 16:53
mikasenghaas added a commit to PrimeIntellect-ai/prime-rl that referenced this pull request Jul 18, 2026
* fix: re-pin verifiers for MCP transport resilience under high concurrency

Bumps deps/verifiers to include the per-call reconnect + retry fix for the v1
user-sim and tool MCP client paths. Under high rollout concurrency on the
subprocess/docker runtimes (e.g. 512 inflight), transient MCP transport drops
(httpx.ReadError -> anyio.ClosedResourceError) at rollout-start churn previously
failed rollouts (surfaced as "ExceptionGroup: unhandled errors in a TaskGroup"
-> HarnessError). The stateless-HTTP MCP servers are now reconnected per call
and transient drops retried, so the churn is invisible to rollout success.

Verified e2e at 512 concurrency / 1 step on the subprocess runtime:
- alphabet-sort-v1 (user sim): 15.1% -> 0.0% rollout error
- gsm8k-v1 (vanilla): 0.0%
- wiki-search-v1 (tool): 0.0%

See PrimeIntellect-ai/verifiers#2062.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin to include retrying() refactor

Follow-up to the MCP transport-resilience re-pin: bumps deps/verifiers to the
revision where the host-side user MCP path reuses the shared retrying() policy
(no functional change vs the prior pin — same per-call reconnect semantics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (public is_transient)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (drop redundant comment)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (retry any Exception, drop transport whitelist)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (drop MCP constant comments)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (merge main: default harness renamed to bash)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (parse outside MCP retry scope)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: update example SWE configs for the default->bash harness rename

verifiers#2063 (pulled in by the pin bump) renamed the built-in 'default' harness
to 'bash'. Switch the example configs that set harness id 'default' accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (at-most-once MCP calls, setup-only retries)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (exactly-once user turns via replay cache)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (serialize user respond)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (retried-opening injection fix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (distinct mid-conversation seq)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (OpenAI SDK timeouts, trimmed user-turn plumbing)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: bump verifiers pin (replay-cache commit ordering + tenacity harness retries)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: re-pin verifiers to merged main (MCP transport resilience)

verifiers#2062 merged as 4a3c1eb4c; pin deps/verifiers at verifiers main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants