Skip to content

Follow-up hardening: MCP init retry, safe HTTP retry semantics, MCP server protocol compliance#28

Merged
alxxjohn merged 3 commits into
improvefrom
hardening/mcp-and-retry-followup
Jul 2, 2026
Merged

Follow-up hardening: MCP init retry, safe HTTP retry semantics, MCP server protocol compliance#28
alxxjohn merged 3 commits into
improvefrom
hardening/mcp-and-retry-followup

Conversation

@alxxjohn

@alxxjohn alxxjohn commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #27, fixing the remaining medium-severity items from the reliability audit. Stacked on improve — retarget to main (or let GitHub auto-retarget) once #27 merges.

MCP adapter: transient init failure no longer poisons the run (cleanr/adapters/mcp.go)

The initialize handshake ran under a sync.Once that cached the first error forever — one expired deadline or a briefly-restarting server made every remaining scenario fail instantly with the stale error. Failed attempts now retry on the next Invoke; a mutex preserves the old serialization of concurrent first invokes.

Safe retry semantics in doWithRetry (cleanr/adapters/http.go, shared by HTTP/OpenAI/Anthropic adapters)

  • Closed-body bug: when a 429/503's Retry-After exceeded the remaining context budget, the response was returned with an already-closed body — callers saw read on closed response body instead of the actual rate-limit response. The body is now closed only after committing to another attempt, and drained first so the keep-alive connection is reused rather than re-handshaking against an already-degraded server.
  • Non-idempotent replays: transport errors were retried for every method, but a connection can die after the request was delivered — replaying a POST risks duplicate writes or double LLM charges. Transport-error retries are now limited to GET/HEAD/OPTIONS; 429/503 responses still retry for all methods (the server explicitly rejected the request, so a replay cannot double-apply it).

MCP server protocol compliance (internal/mcpserver)

  • Ids are kept as raw JSON so id: 0 / id: "" round-trip exactly (omitempty silently dropped them, breaking client correlation), and parse errors carry the spec-required id: null.
  • A final request without a trailing newline (one-shot piped clients) is answered at EOF instead of silently dropped.
  • Tool execution failures return isError: true results the calling model can read and self-correct from; unknown tools are -32602; contained panics remain -32603. Previously every failure was a blanket -32603, which some clients treat as a broken server.

Behavior changes to be aware of

  • POSTs no longer retry on transport errors (connection refused/reset). Targets behind flaky networks will surface those errors instead of silently retrying — intentional, since the alternative is potential duplicate mutations/charges. 429/503 handling is unchanged.
  • MCP clients that matched on -32603 for argument errors will now see isError tool results (spec-conformant).

Test plan

  • cleanr-dev check (gofiles, fmt, vet, go test -race -shuffle=on ./...) — 431 passed, 0 failed
  • New regression tests: init retried after transient failure; 429-over-budget body readable; POST not replayed on transport error; GET transport retry + POST 503 retry still work; unterminated final line answered; falsy id echo; id: null on parse error; isError vs -32602 vs -32603 failure modes

🤖 Generated with Claude Code

alxxjohn and others added 3 commits July 2, 2026 13:55
The initialize handshake ran under a sync.Once that stored the first
error forever: if the first scenario's deadline expired mid-handshake or
the server was briefly restarting, every remaining scenario in the run
failed instantly with the stale error. A failed attempt is no longer
cached — the next Invoke retries, with a mutex preserving Once's
serialization of concurrent first invokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two fixes to the shared HTTP retry loop (used by the HTTP, OpenAI, and
Anthropic adapters):

- When a 429/503's Retry-After exceeded the remaining context budget,
  the response body was closed before being returned, so callers got
  'read on closed response body' instead of the actual rate-limit
  response. The body is now closed only after committing to another
  attempt — and drained first so the keep-alive connection is reused
  instead of forcing a new TCP+TLS handshake against an
  already-degraded server.
- Transport errors were retried for every method, but a connection can
  die after a request was fully delivered — replaying a POST there
  risks duplicate writes or double charges. Transport-error retries are
  now limited to idempotent methods (GET/HEAD/OPTIONS); 429/503
  responses still retry for all methods since the server explicitly
  rejected the request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tool results

Three protocol-compliance fixes for the stdio MCP server:

- Request ids are kept as raw JSON so id: 0 and id: "" round-trip
  exactly (omitempty previously dropped them, breaking client
  correlation), and parse errors now carry the spec-required id: null.
- The final request of a session without a trailing newline (common for
  one-shot piped clients) is processed at EOF instead of silently
  dropped.
- Ordinary tool execution failures return isError results the calling
  model can read and self-correct from; unknown tools are -32602 and
  contained panics remain -32603, instead of every failure surfacing as
  a blanket internal error that some clients treat as a broken server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alxxjohn alxxjohn merged commit 84b8567 into improve Jul 2, 2026
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.

1 participant