Skip to content

Do not resend a non-idempotent request after a session-expiry 404 - #209

Merged
simonx1 merged 1 commit into
mainfrom
fix/session-restart-tools-call-replay
Aug 4, 2026
Merged

Do not resend a non-idempotent request after a session-expiry 404#209
simonx1 merged 1 commit into
mainfrom
fix/session-restart-tools-call-replay

Conversation

@simonx1

@simonx1 simonx1 commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

Found by Codex during the adversarial review of the 2.1.0 release PR (#208), reproduced before fixing.

#196 stopped tools/call being replayed by with_retry, on the grounds that a "transient" failure can arrive after the server executed the request. Session recovery was a second, independent path around that same guarantee. Any session-bearing HTTP 404 calls restart_session_and_resend, which re-sent the original request without consulting NON_IDEMPOTENT_METHODS:

tools/call POSTs: 2
sequence: ["initialize", "notifications/initialized", "tools/call",
           "initialize", "notifications/initialized", "tools/call"]

A 404 usually does mean the request was rejected outright — but it does not prove it. A session can expire after the tool ran, which is precisely the ambiguity the no-replay rule exists for. This is also the sharper version of the problem, because unlike a retry it happens even with retries: 0.

Fix

resend_after_session_restart gates the resend on the same NON_IDEMPOTENT_METHODS constant:

  • The session is still restarted, so recovery keeps working for idempotent requests (tools/list, resources/read, …).
  • A non-idempotent request is not re-sent. It raises ConnectionError — chosen deliberately because with_retry never rescues that class, so no other path can quietly turn this into a second attempt — with a message saying the request was not resent because it may already have executed.
  • Both call sites route through the helper, including the one that skips re-initialization when another caller already restarted the session (that path resent too).

After:

tools/call POSTs: 1  → MCPClient::Errors::ConnectionError

Testing

Two regression tests in streamable_session_lifecycle_spec.rb:

  • a tools/call hitting an expired session is attempted exactly once, raises, and the session is restarted (asserting we didn't fix this by disabling recovery);
  • an idempotent tools/list is still re-sent against the fresh session.

Full suite 1695 examples / 0 failures, RuboCop clean.

Note

The behaviour change is user-visible: a tool call interrupted by an expired session now raises instead of transparently recovering. It is documented in the 2.1.0 CHANGELOG and README in #208, which will pick this up once merged.

🤖 Generated with Claude Code

2.1.0 made tools/call non-retryable (#196) because a "transient" failure
can arrive after the server executed the request. Session recovery was a
second, independent path around that guarantee: any session-bearing
HTTP 404 calls restart_session_and_resend, which re-sent the original
request without consulting NON_IDEMPOTENT_METHODS. A tools/call whose
session expired therefore produced two identical POSTs.

A 404 usually does mean the request was rejected outright, but it does
not prove it — a session can expire after the tool ran, which is exactly
the ambiguity the no-replay rule exists for.

The session is still restarted, so recovery keeps working for idempotent
requests. A non-idempotent request is not re-sent; it raises
ConnectionError, which with_retry never rescues, so no other path can
turn it into a second attempt. Both call sites route through the new
helper, including the one that skips re-initialization when another
caller already restarted the session.

Found by Codex adversarial review of the 2.1.0 release PR (#208) and
reproduced before fixing: two tools/call POSTs before, one after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@simonx1
simonx1 merged commit 6061ff8 into main Aug 4, 2026
4 checks passed
simonx1 added a commit that referenced this pull request Aug 4, 2026
Version bump, changelog, and the documentation the release needs.

- lib/mcp_client/version.rb and the Gemfile.lock self-reference move to
  2.1.0; the gemspec reads the constant, so it needs no edit.

- CHANGELOG documents the PRs merged since 2.0.0 (#188-#210) as one
  coherent story, with the behavior changes hosts must act on called out
  under Breaking Changes and Migration notes: tools/call is no longer
  auto-retried (including through session recovery), task operations
  refuse to guess a server, peer-facing error messages are constant, and
  logs no longer carry payloads.

- README gains a "Treating the Server as Untrusted" section summarizing
  what the transports now refuse from a peer, documents the retry
  semantics for tools/call, and documents max_decompressed_body_bytes.
  Two claims are deliberately narrow: the OAuth check is textual (DNS is
  not resolved) and the response-size limit is Streamable HTTP only,
  since that is the only transport requesting gzip.

Two defects found while writing the docs, both fixed here:

- max_decompressed_body_bytes could not actually be set through the
  documented path. streamable_http_config did not accept it and
  ServerFactory did not forward it, so the option only worked when
  constructing ServerStreamableHTTP directly. Wired through both, with
  specs asserting the config path, the default, and the validation.

- The README claimed "No runtime dependencies" while the gemspec
  declares faraday, faraday-follow_redirects, faraday-retry and base64.

Metrics/ParameterLists now sets CountKeywordArgs: false. The *_config
builders are keyword-only option factories, and the codebase already
carried six hand-written disables for exactly this; those are removed.

The two code bugs Codex found while reviewing this release ship
separately in #209 and #210.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simonx1 added a commit that referenced this pull request Aug 4, 2026
## Summary

Releases **2.1.0**, covering the PRs merged since 2.0.0 (#188#210): a
security pass over every transport, plus Ruby 4.0.6 support.

> **Rebased.** Codex's review of this PR found two real code bugs.
Rather than smuggle them into a release commit, they shipped separately
as **#209** (`tools/call` replayed by session recovery) and **#210**
(peer payloads reaching the SSE error logs), both merged. This branch
was rebuilt on top of them, so its diff is now version + docs + the two
doc-driven fixes below — no security code changes. The review comment
below has the reproductions.

The theme is one sentence: **a remote MCP server is untrusted input.**
2.0.0 was correct against a cooperative server but assumed good faith in
places where a hostile — or merely compromised — peer controls the data.
The wire protocol is unchanged and the ordinary client API is unchanged;
what changed is what the client accepts, retries, logs and reflects
back.

## What's in the release

| | |
|---|---|
| Version | `lib/mcp_client/version.rb`, `Gemfile.lock` self-reference →
**2.1.0** (gemspec reads the constant) |
| CHANGELOG | New 2.1.0 entry in the established format, with **Breaking
Changes** and **Migration notes** |
| README | New "Treating the Server as Untrusted" section; `tools/call`
retry semantics; `max_decompressed_body_bytes` |

### Behaviour changes hosts must act on

These are spelled out in the CHANGELOG's Migration notes:

- **`tools/call` is never retried automatically** — including through
session-expiry recovery (#209). A "transient" failure can arrive after
the server executed the request, and JSON-RPC has no idempotency key to
make a replay safe. Retry explicitly if your application knows it is
safe, and treat the error as *outcome unknown*.
- **Task operations refuse to guess a server.** Pass the `Task` handle
from `call_tool_as_task`, or name the server. A bare ID still works with
one configured server; with several it raises `ArgumentError` rather
than acting on the wrong one.
- **Peer-facing error messages are constant** and **DEBUG logs no longer
contain payloads** — if you parsed either for detail, the detail now
lives only in local logs. Error codes are unchanged.
- **Cross-origin SSE endpoints/redirects and non-HTTPS or loopback OAuth
discovery URLs from a peer are refused.** Local development still works
when the *configured* server is itself local.

## Two defects found while writing the docs

Writing the documentation turned out to be a review in its own right:

1. **`max_decompressed_body_bytes` could not be set through the
documented path.** `streamable_http_config` did not accept it and
`ServerFactory` did not forward it, so the option introduced in #188
only worked when constructing `ServerStreamableHTTP` directly — i.e. the
knob was effectively unreachable for anyone using the normal config API.
Wired through both, with specs asserting the config path, the default,
and the positive-integer validation.
2. **The README claimed "No runtime dependencies"** while the gemspec
declares `faraday`, `faraday-follow_redirects`, `faraday-retry` and
`base64`. Pre-existing, corrected here.

## RuboCop config

`Metrics/ParameterLists` now sets `CountKeywordArgs: false`. The
`*_config` builders are keyword-only option factories where each keyword
is a documented, defaulted setting; counting them against a
positional-argument limit would push new options into an opaque hash.
The codebase already carried **six hand-written `rubocop:disable
Metrics/ParameterLists`** comments for exactly this reason — those are
now removed, which is what the 58 autocorrections in the diff are.

## Verification

| Check | Result |
|---|---|
| RSpec, Ruby 4.0.6 (default) | **1698 examples, 0 failures** |
| RSpec, Ruby 3.3.5 | **1698 examples, 0 failures** |
| RuboCop | 136 files, no offenses |
| `gem build` | builds `ruby-mcp-client-2.1.0.gem`; ships lib + LICENSE
+ README only (no specs/examples/secrets) |
| `Gem::Specification#validate` | passes |
| Version consistency | `version.rb`, `Gemfile.lock`, gemspec all report
2.1.0 |
| Examples suite (pre-release, on merged main) | 18 PASS · 0 FAIL · 2
SKIP |

The two example skips are environmental: no `ANTHROPIC_API_KEY` in this
environment, and the interactive browser-OAuth example.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <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.

1 participant