Skip to content

Keep peer payloads out of the SSE error-logging paths - #210

Merged
simonx1 merged 1 commit into
mainfrom
fix/payload-logging-error-paths
Aug 4, 2026
Merged

Keep peer payloads out of the SSE error-logging paths#210
simonx1 merged 1 commit into
mainfrom
fix/payload-logging-error-paths

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.

#198 replaced payload logging with method/id summaries — but only on the paths that log a successfully parsed message. The paths that log because something was unexpected still wrote the value verbatim:

Site Level What leaked
parse_sse_event_data (server_streamable_http/json_rpc_transport.rb) WARN a valid but non-object JSON payload, via #inspect
handle_server_message (server_streamable_http.rb) DEBUG malformed event data, verbatim

The WARN one is the worse of the two: the default logger emits WARN, so that path leaked without anyone enabling DEBUG. A peer that sends "patient-record-456" as SSE data got it written straight into the host's log.

Fix

Both now log type and size only:

@logger.warn("Skipping non-object JSON-RPC message in SSE event (#{message.class})")
@logger.error("Invalid JSON in server message: #{e.message} (#{describe_body_size(data)})")

The JSON::ParserError message names the failure position, not the payload, so it is safe to keep.

A related robustness bug the reproduction exposed

A JSON-parseable scalar or array on the GET events stream reached dispatch_server_message, which calls #key? on it and raised NoMethodError. It was caught upstream by process_event_chunk's generic rescue — so it never surfaced, but only by luck, and it aborted processing of that chunk. Non-object messages are now skipped with a typed warning, matching what the POST response path already did.

Testing

New spec asserts sentinel values from both a non-object payload and a malformed one never appear in the log, while the typed warning does. Full suite 1694 examples / 0 failures, RuboCop clean.

🤖 Generated with Claude Code

2.1.0 replaced payload logging with method/id summaries (#198), but only
on the paths that log successfully parsed messages. The paths that log
*because* something was unexpected still wrote the value:

- parse_sse_event_data logged a valid but non-object JSON payload with
  #inspect at WARN, which the default logger emits — so this leaked
  without anyone enabling DEBUG;
- handle_server_message logged malformed event data verbatim at DEBUG.

Both now report type and size only. A reproduction that sent a scalar
containing a sentinel value found it in the WARN log before, and nothing
after.

The same reproduction exposed a related robustness bug: a JSON-parseable
scalar or array on the events stream reached dispatch_server_message,
which calls #key? on it and raised (caught upstream by the chunk
handler's generic rescue, but only by luck). Non-object messages are now
skipped with a typed warning, matching what the POST response path
already did.

Found by Codex adversarial review of the 2.1.0 release PR (#208).

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 5633547 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

Found by Codex in a follow-up review of the 2.1.0 release PR (#208),
verified before fixing.

`JSON::ParserError#message` **quotes the offending token**:

```
JSON.parse('{LEAKME-123-45-6789 bad}')
# => expected object key, got 'LEAKME-123-45-6789' at line 1 column 2
```

#210 replaced the log lines that printed payloads directly, but kept
`e.message` — on my assumption that it carried only a position. It
doesn't, so peer-controlled bytes still reached logs.

The leak was reachable on **every** JSON receive path:

| Path | Channel |
|---|---|
| GET events stream (`server_streamable_http.rb`) | `logger.error` |
| POST SSE response parser
(`server_streamable_http/json_rpc_transport.rb`) | `logger.warn` —
**default-visible** |
| Legacy SSE parser (`server_sse/sse_parser.rb`) | `logger.warn` —
**default-visible** |
| OAuth token-refresh (`auth/oauth_provider.rb`) | `logger.warn` — and
the payload there is a *token response* |
| All four transports' `Invalid JSON response from server` |
`TransportError` message, which hosts routinely log |

## The test that gave false assurance

Codex also spotted that the regression test I added in #210 **passed by
accident**: its sentinel appeared *after* the first invalid token, so
the parser never quoted it. That is the more useful half of the finding
— the fix looked verified when it wasn't.

The new tests place the sentinel as the **first** invalid token on each
path, which is the case that actually exercises the quoting.

## Fix

`describe_parse_error` (in `JsonRpcCommon`, shared by every transport)
keeps what makes a parse failure diagnosable — the `line N column M`
position and the payload byte size — and drops the quoted content:

```
Invalid JSON in server message: malformed JSON, at line 1 column 2, 18 bytes
```

I kept the position deliberately rather than logging only a class name:
debugging a server that emits subtly broken JSON is painful without it,
and the position is not peer content.

The `Invalid JSON response from server:` prefix is unchanged, so
existing rescues and the nine specs matching on it are unaffected.

## Testing

New specs assert no sentinel reaches the log on the GET, POST-SSE and
legacy-SSE paths, none reaches the raised `TransportError` for a
malformed HTTP body, and that the position and size survive. Full suite
1699 examples / 0 failures, RuboCop clean.

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

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