Skip to content

MCP write-capable tools are not auto-retried after mid-flight session expiry (no duplicate side effects) (salvage #88821) - #88821

Merged
teknium1 merged 2 commits into
mainfrom
cloudflare-os-port/mcp-write-retry-at-most-once
Sep 15, 2026
Merged

teknium1 merged 2 commits into
mainfrom
cloudflare-os-port/mcp-write-retry-at-most-once

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Summary

A write-capable MCP tool call that fails with a session-expired/transport error is no longer auto-retried — the request may already have executed server-side, and a blind retry risks a duplicate side effect MCP offers no way to undo. Ported from cloudflare/cloudflare-os#168 ("Harden MCP connection lifecycle").

Root cause of the risk: our _SESSION_EXPIRED_MARKERS classifier matches both genuine pre-dispatch rejections ("session not found") AND failure shapes that routinely fire after dispatch — ClosedResourceError, "broken pipe", "connection closed" mid-response, or a proxy-synthesized session-404 after the upstream accepted the request. The previous recovery path (_handle_session_expired_and_retry) re-ran every tool call once regardless.

Changes

  • tools/mcp_tool.py: _handle_session_expired_and_retry gains keyword-only call_may_have_side_effects. When True it still heals the transport (reconnect + breaker reset on success) but never re-runs the call; instead it returns a structured outcome_unknown=True tool error telling the model to verify with a read before re-invoking.
  • tools/mcp_tool.py: new _tool_is_read_only(server, tool) helper — reuses the discovery-time readOnlyHint capture the trust gate already maintains. Fails safe: missing/unknown/malformed annotations classify as write-capable.
  • tools/mcp_tool.py: the tools/call handler passes call_may_have_side_effects=not _tool_is_read_only(...). The four read-only handlers (resources/list, resources/read, prompts/list, prompts/get) keep unconditional retry — they are protocol-level reads.
  • The OAuth-401 path keeps its retry for all tools: a 401 means the server demanded authorization before dispatch, so the call never executed (matches upstream's McpAuthRequiredError → safe-to-retry rule).
  • tests/tools/test_mcp_tool_session_expired.py: 4 new tests (write not retried + outcome_unknown, read-only still retries, fail-safe classification, direct helper coverage); existing retry tests updated to register readOnlyHint=True since unannotated tools no longer auto-retry.

Adaptation notes

Upstream models this as callMayHaveTakenEffect(err) over typed error classes (McpCallNotDispatchedError → safe, McpSessionExpiredError → unsafe for writes since a fronting proxy can synthesize it post-dispatch). Hermes' classifier is string/type-marker based with no dispatch-boundary signal, so the port keys the decision on the tool's write-capability instead — the same fail-safe direction ("anything not positively known safe is treated as possibly performed"), wired into the readOnlyHint metadata we already capture for trust-tier gating. No toolset/schema mutation; prompt caching unaffected.

Complementary to open PR #84931 (durable tool-attempt identity in _meta so governed servers can dedupe); this PR removes the client-side duplicate dispatch for ordinary servers.

Validation

Before After
Write tool, session expires mid-flight silently re-dispatched (possible double side effect) 1 dispatch, transport healed, outcome_unknown error returned
Read-only tool (readOnlyHint: true), session expired retry once retry once (unchanged)
resources/prompts handlers retry once retry once (unchanged)
OAuth 401 on any tool recover + retry once recover + retry once (unchanged)

Tests: tests/tools/test_mcp_tool_session_expired.py + test_mcp_trust_gating.py → 28/28; broader MCP subset (tool, 401, circuit breaker, reconnect signal/reset, transport group, failure classification, connect/shutdown, issue-948) → 138/138.

Source: cloudflare/cloudflare-os#168

Infographic

MCP Writes: At Most Once

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 45bd59a — fix: keep the write-side outcome-uncertain verdict when no s

⚠️ Warnings

OSV vulnerability scan · View job

76 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


CI timings · View report · View job

Wall time 9m26s vs 6m9s (+53.4%). 12 job(s) slower, 2 faster, 1 unchanged.

  • Python tests / Run tests: +156.0s
  • Docs Site / docs-site-checks: +50.0s
  • OS-specific tests / Windows-only tests: +31.0s
  • Python lints / Windows footguns (blocking): +20.0s
  • OS-specific tests / macOS-only tests: -12.0s

@alt-glitch alt-glitch added type/bug Something isn't working comp/tools Tool registry, model_tools, toolsets tool/mcp MCP client and OAuth P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 18, 2026
…e MCP tools after mid-flight session expiry

A 'session expired' / transport-closed failure can arrive AFTER the server
already accepted and executed the request (proxy-synthesized 404s, pod
rotation, ClosedResourceError firing mid-response). Auto-retrying a
write-capable tool in that window risks a duplicate side effect that MCP
offers no way to undo.

The session-expired recovery path now consults the discovery-time
readOnlyHint capture (same data the trust gate uses): only tools whose
annotation is exactly True keep the reconnect+retry-once behavior. Write-
capable calls still get the transport healed (reconnect, breaker reset on
success) but return a structured outcome_unknown error telling the model
to verify with a read before re-invoking.

The OAuth 401 path keeps its retry for all tools: a 401 means the server
demanded authorization before dispatch, so the call never executed —
matching the upstream classifier's McpAuthRequiredError => safe rule.

Fails safe: missing/unknown annotations classify as write-capable.
@teknium1
teknium1 force-pushed the cloudflare-os-port/mcp-write-retry-at-most-once branch from 4b3fdfd to 9e565ca Compare September 15, 2026 01:42
@teknium1 teknium1 changed the title MCP write-capable tools no longer risk duplicate side effects on mid-flight session expiry (port cloudflare-os#168) MCP write-capable tools are not auto-retried after mid-flight session expiry (no duplicate side effects) (salvage #88821) Sep 15, 2026
… reconnect

_handle_session_expired_and_retry only reached the at-most-once guard when a
reconnectable server record existed; without one (server torn down, MCP loop
not running) a write-capable call fell through to the generic "MCP call
failed" error, which invites the model to replay a write that may already
have landed. The session-expired classification now runs first and a
write-capable call always gets the outcome_uncertain error; the reconnect is
attempted only when a server can be signalled.

_track_inflight_rpc's teardown RuntimeError said "retry the request on the
rebuilt session" for every op; for a write-capable tools/call it now says the
request may already have been dispatched and must be verified first, so the
wording matches the at-most-once contract the recoverer enforces.

Docs: the readOnlyHint row explains that the same hint gates auto-retry after
a mid-call session expiry, and that unannotated tools on an idle-TTL
Streamable-HTTP server return outcome_uncertain on the first call after idle
instead of being transparently replayed.
@teknium1
teknium1 merged commit 081421d into main Sep 15, 2026
37 checks passed
@teknium1
teknium1 deleted the cloudflare-os-port/mcp-write-retry-at-most-once branch September 15, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants