Skip to content
This repository was archived by the owner on Oct 24, 2026. It is now read-only.

fix(mcp): an unparseable upstream envelope is response_rejected; /mcp guides on rejected responses; correct posture claims - #854

Merged
PetrefiedThunder merged 6 commits into
claude/objective-knuth-d05d5afrom
claude/stoic-noyce-b6787e
Oct 24, 2026
Merged

PetrefiedThunder merged 6 commits into
claude/objective-knuth-d05d5afrom
claude/stoic-noyce-b6787e

Conversation

@PetrefiedThunder

@PetrefiedThunder PetrefiedThunder commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #853. This PR's base is claude/objective-knuth-d05d5a, so the diff shows only the six commits below. Merge #853 first. This repo deletes merged head branches, so GitHub should retarget this PR to main when #853 merges. If #853 is squash-merged, expect this PR to show conflicts or extra files until it is rebased onto main; the real delta is the six commits listed here.

Summary

Follow-up to #853, which stopped a received, well-formed result from being signed delivery_uncertain. This PR closes the neighbouring case, fixes two classification gaps found in review, and corrects claims in the docs that did not hold.

1. An unparseable upstream result envelope is response_rejected

A tools/call response that the upstream sent but whose envelope the SDK cannot parse (content a string, isError not a bool, and similar) ended as delivery_uncertain (504 / -32005, counted by the circuit breaker) through a real SDK session. _call_tool_once intends response_rejected.

Cause: the SDK's anyio task group rewraps anything raised inside the async with self._session() block in an ExceptionGroup, so the adapter's ValidationError branch never matched and the generic handler classified the call by dispatch_started alone. Tests that fake the session do not enter the task group, which is why they passed.

Fix: _call_tool_once records the rejection at the call_tool await, where the SDK raises ValidationError only after the upstream's JSON-RPC result has arrived. The call ends as response_rejected with code upstream_response_invalid (502 / -32006): debit kept, never redispatched, replay returns the same receipt. A call with no response (timeout, dropped connection, a JSON-RPC error answering tools/call) and a malformed initialize answer keep their outcomes.

2. /mcp tells the model not to resend a rejected response

app/routers/mcp_standard.py returned the do-not-resend tool result only for delivery_uncertain. response_rejected is also charged (the call was dispatched and the debit stands), but it became a bare -32006, the same code that refunded, retry-safe failures use. A model that sees only the message could resend a call whose charge stands and whose tool probably ran.

response_rejected now returns the same guided result as delivery_uncertain: isError: true, text saying the upstream answered but the answer was unusable and the effect is unknown, the signed receipt, and _meta["io.agentmiddleware/outcome"] with status unknown and reason response_rejected. Refunded failures stay bare errors. /mcp/messages and REST are unchanged. Nothing the upstream wrote is relayed.

3. One parse-failure rule for both handlers

The ladder's ValidationError handlers could label a request-build error (for example arguments with a non-string key: the claim is committed and no tools/call reaches the upstream) as response_rejected, which asserts the upstream answered. Both handlers now use _is_unparseable_response: after dispatch, a CallToolResult validation error or a JSON decode error is response_rejected, and any other validation error is delivery_uncertain. The branch also gained the "a result is already in hand" guard its two sibling handlers have.

4. Docs: claims that did not hold

Four review passes, each correcting claims in docs/mcp-security-posture.md, docs/exit-and-export.md, docs/failure-semantics.md, docs/gateway-overhead.md, CHANGELOG.md and READMEs. The substantive ones:

  • Endpoint decision recorded. POST /mcp/messages and the REST invoke route are the live governed path in the supported pilot. POST /mcp ships off (ENABLE_STANDARD_MCP_ENDPOINT defaults to false) and the runbook keeps it off.
  • File:line citations re-pinned. Of 17, 15 had moved (the base was more than a thousand commits older). Each is re-pinned and the claim beside it re-read.
  • idempotency_key_required is a refusal for a call that names a permit and sends no key, not for every governed call. mcp.py replaces a missing key with a server-derived act1- key before that check. The digest covers the action permit's identity only, not the payload.
  • Deletes. The API-key DELETE route only revokes (status, revoked_at, revoke_reason) and keeps the row. In the default configuration no mounted DELETE route deletes anything.
  • Opt-ins that exist. KMS and row-level security exist as opt-ins, default off. Optional tracing exists (OTEL_TRACING_ENABLED, default off).
  • Still delivery_uncertain, now documented. A body that is not a JSON-RPC message at all (non-JSON text, jsonrpc 1.0, neither result nor error, unknown request id) is logged by the SDK transport, never raised, and ends delivery_uncertain after the call timeout.
  • Gateway overhead re-measured twice on this base: first call p50 29.5-30.9 ms, replay p50 5.4-5.6 ms. Results are in docs/research/gateway-overhead-2026-10-23/. The public site's figure is not touched.

Behavior changes

  • A malformed envelope no longer counts toward the optional circuit breaker, like the other response_rejected causes.
  • POST /mcp returns response_rejected as an isError tool result with do-not-resend text. This is a change to what an MCP client sees on that endpoint, which ships off by default.
  • docs/openapi.json is regenerated with scripts/export_openapi.py. It differs by the REST invoke and /mcp/messages descriptions only: no schema or path changes.

Deliberately unchanged

  • The plugin's mcp.json, which targets /mcp on the first-party host. That is a product decision.
  • The machine-read discovery labels legacy_project_transport and legacy_project_json_rpc.
  • The public site's overhead figure.

Tests

  • Real SDK session behind the real adapter, in memory and over the production Streamable HTTP client, including a fixture guard that proves the envelope reaches the client and fails there (a sender-side crash looks identical from the adapter's side).
  • A non-wrapping session double covers both directions of the shared rule.
  • /mcp end to end: an unparseable envelope, an oversized result, and a bearer echo each give a readable tool result, a receipt, one debit and no redispatch on replay, and relay nothing the upstream wrote.
  • A spy test pins the gateway outcome recorded on /mcp (failed for a rejected response, unknown for an undelivered one).
  • A test that POST /mcp honours a permit the caller names, enforcing forbidden_fields with a signed denied receipt.
  • Governed-router receipt test: one debit, one dispatch, a response_rejected receipt, no redispatch on replay, breaker state.

CI is the gate for this PR. The author session did not re-run the full suite on this branch tip before opening it, and has no numbers to quote for it. The last two commits are text only: no .py file differs between 93aee1ab and 8c2b4428.

Not covered

  • No real third-party MCP server.
  • The "not a JSON-RPC message at all" case is documented but not fixed: it still waits out the call timeout and ends delivery_uncertain.

Open owner decisions (not made here)

  • Discovery still labels the live path "legacy".
  • REST invoke is not advertised in discovery.
  • The plugin mcp.json URL.

🤖 Generated with Claude Code


Note

High Risk
Changes charged-call outcome semantics, circuit-breaker counting, and default-off POST /mcp client-visible errors; mistakes could cause double charges or unsafe retries.

Overview
Fixes misclassification of upstream tools/call responses the MCP SDK cannot parse: they now finalize as charged, terminal response_rejected (upstream_response_invalid, 502 / -32006) instead of delivery_uncertain. The adapter records CallToolResult validation failures at the call_tool await (before the SDK task group wraps them in ExceptionGroup) and applies _is_unparseable_response consistently so request-build errors stay uncertain while parse failures are rejected. Session-close errors no longer overwrite a known result or rejection.

On POST /mcp, response_rejected is returned like delivery_uncertain: an isError tool result with do-not-resend guidance and outcome metadata (refunded -32006 failures remain JSON-RPC errors). Malformed envelopes no longer feed the circuit breaker.

Docs and API copy record that /mcp/messages and REST invoke are the supported pilot paths (POST /mcp off by default), refresh security/failure/troubleshooting claims, re-pin citations, re-measure gateway overhead, and regenerate OpenAPI descriptions. Tests add real SDK sessions (memory + Streamable HTTP) and governed-router coverage for receipts, breaker behavior, and standard-endpoint tool results.

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

PetrefiedThunder and others added 6 commits October 22, 2026 18:22
…d, not delivery_uncertain

A tools/call response the upstream sent but whose envelope the MCP SDK cannot
parse (content a string, isError not a bool, ...) ended as delivery_uncertain
(504 / -32005, counted by the circuit breaker) through a real SDK session,
though _call_tool_once intends response_rejected. The SDK's anyio task group
rewraps anything raised inside the session block in an ExceptionGroup, so the
adapter's ValidationError branch never matched and the generic handler
classified the call by dispatch_started alone. Tests that fake the session do
not enter the task group, which is why they passed.

_call_tool_once now records the rejection at the call_tool await, where the
SDK raises ValidationError only after the upstream's JSON-RPC result has
arrived, before the session unwinds. The call ends as response_rejected with
code upstream_response_invalid (502 / -32006): debit kept, never redispatched,
replay returns the same receipt. Precedence is unchanged: claim contention,
guard rejection, a known received result, this rejection, then
delivery_uncertain only when no response was obtained. A call with no response
(timeout, dropped connection, JSON-RPC error answering tools/call) and a
malformed initialize answer keep their outcomes.

The ValidationError branch also lacked the "a result is already in hand" guard
its two sibling handlers have, so a ValidationError or JSONDecodeError raised
only while the session closes could turn a received result into
response_rejected or replace a known rejection's code. It has the guard now.

Behavior change: a malformed envelope no longer counts toward the optional
circuit breaker, like the other response_rejected causes.

Tests drive a real SDK session behind the real adapter (in memory and over the
production Streamable HTTP client), including a fixture guard that the
envelope reaches the client and fails there, the no-response boundary, a
secret-echo check, and a governed-router receipt test (one debit, one
dispatch, a response_rejected receipt, no redispatch on replay, breaker state).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ect only unparseable results

Two review findings on 7dbcd6d.

1. /mcp (app/routers/mcp_standard.py) turned only delivery_uncertain into a
   tool result carrying the do-not-resend text. response_rejected, which is
   also charged (the call was dispatched and the debit stands), became a bare
   -32006, the code that refunded, retry-safe failures
   (upstream_returned_error, upstream_pre_dispatch_failed) share. A model that
   sees only the message could resend a call whose charge stands and whose tool
   probably ran. response_rejected now returns the same guided result:
   isError true, text saying the upstream answered but the answer was unusable
   and the effect is unknown, the signed receipt, and
   _meta["io.agentmiddleware/outcome"] with status "unknown" and
   reason "response_rejected". The gateway outcome recorded for insights stays
   "failed", as it was on the JSON-RPC channel. Refunded failures stay bare
   errors; /mcp/messages and REST are unchanged.

2. The inner except ValidationError in _call_tool_once also caught a pydantic
   error raised while the SDK builds the request, before anything is sent
   (arguments with a non-string key: the claim is committed, no tools/call
   reaches the upstream, and the call was labeled response_rejected, which
   asserts the upstream answered). It now counts only a ValidationError for
   CallToolResult; any other falls through to the generic handler as before
   (delivery_uncertain, conservative since the checkpoint was claimed).

Tests: a /mcp end-to-end test with a real SDK upstream answering an
unparseable envelope (readable tool result, receipt, one debit, no redispatch
on replay), the existing test that upstream_returned_error stays a bare error,
and a real-SDK test that an unsendable request is not a rejected response. The
fake-session test now raises a genuine CallToolResult validation error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…easure gateway overhead

Follow-up to 0698168.

Endpoint decision. POST /mcp/messages and the REST invoke route are the live
governed path in the supported pilot. POST /mcp ships off
(ENABLE_STANDARD_MCP_ENDPOINT defaults to false) and the production runbook
keeps it off, because it mints a short-lived single-tool permit and generates
an idempotency key on the caller's behalf. README.md and SECURITY_LIMITATIONS.md
no longer call it the supported or preferred path today. The posture doc
records the decision instead of describing a contradiction, and the overhead
page no longer calls /mcp/messages "not the supported endpoint".
docs/deploy-railway.md and docs/mcp-registry-submission.md were already
correct and are untouched.

Citations. The file:line citations in docs/mcp-security-posture.md and
docs/gateway-overhead.md were written against a base more than a thousand
commits older. Of 17, 15 had moved, one SDK range was a line short and
mcp.py:15 was still right. Each is re-pinned and the claim beside it re-read.
Corrected: idempotency_key_required applies to every governed call (including
any call in strict trust mode), not only permit-bound ones; a signed denied
receipt is issued only when the permit names the caller's wallet; the SDK
validation range is 412-448; exit-and-export.md said no OpenTelemetry
instrumentation exists, but optional tracing (OTEL_TRACING_ENABLED, default
off) does; the poisoning-test docstring named one call site of
register_configured_upstream_mcp where scripts/failure_lab.py is a second.

Response classification docs. failure-semantics and the posture page now state
the caveat that a body that is not a JSON-RPC message at all (non-JSON text,
jsonrpc 1.0, neither result nor error, unknown request id) still ends as
delivery_uncertain after the call timeout, each observed against a scripted
transport; that with the reconcile worker enabled response_rejected is
terminal and never probed or refunded; and that /mcp now returns
response_rejected as a readable tool result. The CHANGELOG no longer says
precedence is unchanged for the outer ValidationError branch, which gained the
guard, and describes the transport behaviour accurately. TROUBLESHOOTING gains
a response_rejected section and authority-required-flow names the renamed
helper.

Gateway overhead re-measured twice on this base (commit 0698168, git_dirty
false, load average recorded): first call p50 29.5-30.9 ms, replay p50
5.4-5.6 ms (earlier runs 29.5-31.9 and 4.4-4.7). Results in
docs/research/gateway-overhead-2026-10-23/. The public site's figure is not
touched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…laims; finish the endpoint wording

Second review of 0698168 and 6638679.

Code. The outer ValidationError / JSONDecodeError branch of _call_tool_once
still answered response_rejected for any such error after dispatch, so a
session that does not wrap exceptions would again label the request-build
error response_rejected. Both handlers now use one rule,
_is_unparseable_response: after dispatch a CallToolResult validation error or a
JSON decode error is response_rejected, and any other validation error is
delivery_uncertain.

Tests. A non-wrapping session double covers both directions. The /mcp
guided-result test now covers an oversized result and a bearer echo as well as
an unparseable envelope, and asserts nothing the upstream wrote is relayed. A
spy test pins the gateway outcome recorded on /mcp (failed for a rejected
response, unknown for an undelivered one); a mutation that always marked
"unknown" used to pass every test. A new test pins that POST /mcp honours a
permit the caller names, enforcing forbidden_fields with a signed denied
receipt, and mints an unconstrained permit only when the reference is omitted.
The keyless test asserts the generated key is readable in the receipt as the
forwarded key, and the comment in mcp_standard.py that said it is never
returned is corrected.

Docs, claims that did not hold. The posture page said operator permit
constraints apply only on /mcp/messages and REST (not so when the caller names a
permit), that there is no KMS and no row-level security (both exist as opt-ins,
default off), and listed governed_call causes incompletely. It now also notes
that the refunded upstream_returned_error relays the upstream's result payload
in error.data on all three surfaces. exit-and-export.md no longer says traces
are not produced and says the only mounted DELETE route that deletes anything
is for API keys. gateway-overhead.md quotes only the first-call p99 and max and
records the kernel release rather than a macOS version the result files do not
contain. The poisoning-test docstring lists all three call sites.

Endpoint wording, leftovers. The REST invoke route's OpenAPI description no
longer tells every integration to call POST /mcp; docs/openapi.json is
regenerated with scripts/export_openapi.py and differs by that one line. The
Cursor plugin README and skill say the standard endpoint ships off. README
names the REST invoke route as supported; authority-required-flow and two test
docstrings no longer present /mcp as the surface integrations are told to use.
The plugin's mcp.json, which targets /mcp on the first-party host, is
deliberately unchanged: that is a product decision.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ssages is the live path

Third review of 67d56b3. Text and served text only; no behavior change.

Refuted claims. The posture page and CHANGELOG said an action-bound call
without an idempotency key is refused with idempotency_key_required on
/mcp/messages and REST. It is not: mcp.py replaces the key with a
server-derived act1- key (action_permits.py) before that check, and a call that
names no permit is refused earlier as permit_required. The refusal is for a
call that names a permit and sends no key. exit-and-export.md and the CHANGELOG
said the API-key DELETE route is the one that deletes something; it only
revokes (status, revoked_at, revoke_reason) and keeps the row, so in the
default configuration no mounted DELETE route deletes anything.

Served text. The /mcp/messages OpenAPI description called it a legacy endpoint
"kept for existing clients and the local proof scripts" and named only /mcp,
which ships off, as an alternative. It now says it is the live governed path in
the supported pilot and why it is still marked deprecated (a project transport
that does not implement the standard MCP initialization lifecycle, to be
replaced by /mcp once an operator enables it). The REST invoke description and
site/docs-src/http-api.md get the same fix. The description keeps its line
count so the cited mcp.py lines do not move. docs/openapi.json is regenerated
under requirements.lock and differs by those two descriptions. The
machine-read discovery labels (legacy_project_transport,
legacy_project_json_rpc) are deliberately unchanged.

Smaller fixes. The _call_tool_once docstring now says only a CallToolResult
parse failure is rejected, while a body that is not a JSON-RPC message is
logged by the SDK transport, never raised, and ends delivery_uncertain at the
timeout. On REST the relayed upstream text is detail.upstream_result in the
502 body. The CHANGELOG lists the other causes of response_rejected, which
/mcp matches on by reason. Two comments that contradicted corrected claims are
fixed (mcpContext.permit_id is read; the keyless key is readable in the
receipt). failure-semantics cites a test that exercises /mcp/messages and says
the REST status is untested. The Cursor plugin READMEs name REST invoke as a
live path and carry the ships-off caveat; mcp.json is untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ecisely

Fourth review of 93aee1a. Text only; no .py file differs from that commit.

The posture page said the server derives an act1- key "from the permit and
payload". The digest (action_permits.py) covers only the action permit's
identity: contract version, permit id, subject wallet, public tool id and
upstream binding hash. The permit's action_payload_hash goes into the request
payload, not the key. It now says so.

The same row said a governed call that names no permit is refused earlier as
permit_required. Two checks answer first: an action-bound tool called without
an action permit is action_permit_required (mcp.py:1672-1676), and an unknown
tool is "Tool not found" (mcp.py:1740-1750). Both are named, with the lines.

An older CHANGELOG entry and failure-semantics.md still said
idempotency_key_required applies to every governed call, including any call in
strict trust mode. Both now scope it to a call that names a permit and sends no
key, matching the posture row. exit-and-export.md called the API-key route "the
one mounted DELETE route" in a sentence that also names two DELETE stubs; it
now says the API-key route only revokes and the stubs refuse, so no default
mounted DELETE route deletes anything.

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

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T05:25:40.886548Z 8c2b442 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

PetrefiedThunder added a commit that referenced this pull request Oct 24, 2026
…ces are produced

Review finding on #853. docs/exit-and-export.md said no OpenTelemetry
instrumentation and no OTLP export exist, in the Traces row of section 1 and
again in section 3. app/core/tracing.py configures an OTLPSpanExporter and
app/main.py installs the tracing middleware (install_tracing), enabled by
OTEL_TRACING_ENABLED, default off, with the otel extra installed. A customer
following the exit procedure could overlook spans retained in a collector the
operator runs.

Both places now say optional tracing exists and is off by default, name the
six spans (the allowed set at app/core/tracing.py:26-31) and the attribute
allowlist (tool name, outcome state, wallet id hash, attempt id), and say
there is no export route and spans live in the operator's collector. Text is
identical to the same two hunks already in the stacked follow-up (#854), so
the two PRs do not conflict on these lines.

Text only; no behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@PetrefiedThunder
PetrefiedThunder merged commit 8a55029 into claude/objective-knuth-d05d5a Oct 24, 2026
2 checks passed
@PetrefiedThunder
PetrefiedThunder deleted the claude/stoic-noyce-b6787e branch October 24, 2026 05:34
PetrefiedThunder added a commit that referenced this pull request Oct 24, 2026
…853)

* Document MCP security posture and exit path, test tool poisoning (battery gates C2, H5, K2)

Self-scoring against the JEV agent-middleware interrogation battery capped
the product at 54/100 on gate C2: no written position on the April 2026
MCP SDK STDIO disclosure. Two more gates lost points for a missing
tool-poisoning scenario (H5) and no written export story (K2).

- docs/mcp-security-posture.md: the MCP attack classes and where each is
  handled, with file-level evidence; the two anti-patterns (confused
  deputy, token passthrough) stated plainly, including the one-service-
  identity gap to the upstream; the April 2026 disclosure as described by
  both CSA notes and the Python SDK advisory list, and why this repo was
  not exposed (no STDIO client anywhere in the tree); the SDK advisories
  that do touch the transports in use; the mcp version pins, including
  the uv.lock specifier drift against pyproject.
- docs/exit-and-export.md: every artifact a customer keeps, its route,
  format, and whether the verifier is independent or first-party; says
  outright that no traces exist and audit-chain verification is
  first-party.
- tests/test_mcp_upstream_tool_poisoning.py: a duplicated tool name is
  refused before any dispatch; a definition rewritten after boot never
  reaches the agent (pinning, not drift detection); the registered
  catalog carries the gateway description, not the upstream's. The
  hostile-description and bearer-echo cases were already covered in
  tests/test_upstream_mcp.py.
- Linked both pages from docs/README.md and SECURITY_LIMITATIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit b8f8806c1a4e2ad9d48f95ad522243752d9054b3)

* Verify and correct the battery-gate docs; add gateway overhead measurement; raise mcp floor

An extract, refute, critique pass over the three new pages and the new test
found 27 refuted claims and about 60 critic findings. All wrong and
misleading ones are fixed; the pages are rewritten where the fix was larger
than a sentence.

Findings that change what a reviewer should believe:
- The real MCP SDK lists tools after a call is dispatched and validates the
  result against the upstream's live outputSchema. Verified with a probe: a
  live schema the result does not satisfy makes the adapter sign
  delivery_uncertain for a call that completed. This withdrew the earlier
  claim that GHSA-rwrf was structurally mitigated; only the 1.30.0 floor
  protects. Pinned by a strict expected-failure test.
- POST /mcp mints its own single-call permit and generates an idempotency key
  when none is sent, so permit constraints and keyed replay protection do not
  apply there. README.md and SECURITY_LIMITATIONS.md call it the supported
  path; docs/deploy-railway.md says never to enable it. Flagged, not resolved.
- The input schema is published verbatim, so it is a residual poisoning
  channel. Recorded by a test instead of described as stripped.

Changes:
- docs/mcp-security-posture.md: rewritten with endpoint table, per-endpoint
  caveats, advisory ranges from the advisory pages, external sources and
  retrieval dates, and the pin situation (uv.lock is gitignored, so it is no
  longer cited).
- docs/exit-and-export.md: rewritten with credentials and rate limits,
  quiesce and deletion caveats, verifier availability (the only published SDK
  release predates the verifier), and the audit chain's real export limits.
- docs/gateway-overhead.md and scripts/measure_gateway_overhead.py: published
  handler time (about 30 ms first call, about 4.5 ms replay, stub upstream,
  SQLite, loaded M4 Pro) with provenance, load, and the figures it does not
  reconcile with. The script now asserts JSON-RPC success and distinct
  receipts, prices the tool under the hourly velocity guard, and records git
  state, load, and package versions.
- tests/test_mcp_upstream_tool_poisoning.py: assertions that cannot fail are
  replaced, tests now go through the registry, and the residual-schema and
  live-schema-drift cases are added.
- requirements.txt, pyproject.toml: mcp floor 1.29.0 to 1.30.0 (fixes five
  SDK advisories; the Docker image installs the floor unpinned).

Full non-proof suite: 2961 passed, 46 skipped, 1 xfailed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
(cherry picked from commit 7356a9017c4e8397282588c79f342fe9c771c726)

* fix(mcp): a well-formed upstream result is never delivery_uncertain

ClientSession.call_tool (mcp 1.30.0) validates a result against the
upstream's live outputSchema after the response arrives, sending a second
tools/list first because the adapter opens a fresh session per call. That
work runs past the dispatch boundary, so schema drift, a failing post-call
tools/list, or a schema demanding structured content raised an exception
that discarded the received result. The adapter then signed
delivery_uncertain for a call that had completed: debit kept, never
redispatched, receipt misstating the outcome, and a hostile upstream could
force it (and count it toward the circuit breaker).

Seed the SDK's per-session schema cache with None for the configured tool
before the dispatch checkpoint, so the SDK sends no post-dispatch tools/list
and runs no schema validation; a received result is judged only by the
adapter's own shape, size, and secret checks. If an mcp release drops the
cache attribute, the call fails closed before the dispatch claim.

Chosen over reclassifying SDK failures as response_rejected because the SDK
raises a bare RuntimeError from inside call_tool and discards the received
CallToolResult, so there is nothing to hash or return and a failed post-call
tools/list cannot be told from a failed tools/call by exception type.

Tests: the strict xfail is now a plain pass; adds drifted schema, failing
post-call tools/list, no structured content, hostile external $ref (outbound
connections made to fail), unchanged normal success, error result and
no-result boundary tests, fail-closed on a missing SDK cache, the production
_session over HTTP, and a receipt-level test through the governed router.
Docs: mcp-security-posture sections 1, 2 and 3.2, failure-semantics,
gateway-overhead, CHANGELOG. Records, but does not fix, a neighbouring defect:
a malformed response envelope still ends as delivery_uncertain through a real
SDK session.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs(exit): optional OpenTelemetry tracing exists; stop saying no traces are produced

Review finding on #853. docs/exit-and-export.md said no OpenTelemetry
instrumentation and no OTLP export exist, in the Traces row of section 1 and
again in section 3. app/core/tracing.py configures an OTLPSpanExporter and
app/main.py installs the tracing middleware (install_tracing), enabled by
OTEL_TRACING_ENABLED, default off, with the otel extra installed. A customer
following the exit procedure could overlook spans retained in a collector the
operator runs.

Both places now say optional tracing exists and is off by default, name the
six spans (the allowed set at app/core/tracing.py:26-31) and the attribute
allowlist (tool name, outcome state, wallet id hash, attempt id), and say
there is no export route and spans live in the operator's collector. Text is
identical to the same two hunks already in the stacked follow-up (#854), so
the two PRs do not conflict on these lines.

Text only; no behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(mcp): an unparseable upstream envelope is response_rejected; /mcp guides on rejected responses; correct posture claims (#854)

* fix(mcp): an unparseable upstream result envelope is response_rejected, not delivery_uncertain

A tools/call response the upstream sent but whose envelope the MCP SDK cannot
parse (content a string, isError not a bool, ...) ended as delivery_uncertain
(504 / -32005, counted by the circuit breaker) through a real SDK session,
though _call_tool_once intends response_rejected. The SDK's anyio task group
rewraps anything raised inside the session block in an ExceptionGroup, so the
adapter's ValidationError branch never matched and the generic handler
classified the call by dispatch_started alone. Tests that fake the session do
not enter the task group, which is why they passed.

_call_tool_once now records the rejection at the call_tool await, where the
SDK raises ValidationError only after the upstream's JSON-RPC result has
arrived, before the session unwinds. The call ends as response_rejected with
code upstream_response_invalid (502 / -32006): debit kept, never redispatched,
replay returns the same receipt. Precedence is unchanged: claim contention,
guard rejection, a known received result, this rejection, then
delivery_uncertain only when no response was obtained. A call with no response
(timeout, dropped connection, JSON-RPC error answering tools/call) and a
malformed initialize answer keep their outcomes.

The ValidationError branch also lacked the "a result is already in hand" guard
its two sibling handlers have, so a ValidationError or JSONDecodeError raised
only while the session closes could turn a received result into
response_rejected or replace a known rejection's code. It has the guard now.

Behavior change: a malformed envelope no longer counts toward the optional
circuit breaker, like the other response_rejected causes.

Tests drive a real SDK session behind the real adapter (in memory and over the
production Streamable HTTP client), including a fixture guard that the
envelope reaches the client and fails there, the no-response boundary, a
secret-echo check, and a governed-router receipt test (one debit, one
dispatch, a response_rejected receipt, no redispatch on replay, breaker state).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(mcp): /mcp tells the model not to resend a rejected response; reject only unparseable results

Two review findings on 7dbcd6d.

1. /mcp (app/routers/mcp_standard.py) turned only delivery_uncertain into a
   tool result carrying the do-not-resend text. response_rejected, which is
   also charged (the call was dispatched and the debit stands), became a bare
   -32006, the code that refunded, retry-safe failures
   (upstream_returned_error, upstream_pre_dispatch_failed) share. A model that
   sees only the message could resend a call whose charge stands and whose tool
   probably ran. response_rejected now returns the same guided result:
   isError true, text saying the upstream answered but the answer was unusable
   and the effect is unknown, the signed receipt, and
   _meta["io.agentmiddleware/outcome"] with status "unknown" and
   reason "response_rejected". The gateway outcome recorded for insights stays
   "failed", as it was on the JSON-RPC channel. Refunded failures stay bare
   errors; /mcp/messages and REST are unchanged.

2. The inner except ValidationError in _call_tool_once also caught a pydantic
   error raised while the SDK builds the request, before anything is sent
   (arguments with a non-string key: the claim is committed, no tools/call
   reaches the upstream, and the call was labeled response_rejected, which
   asserts the upstream answered). It now counts only a ValidationError for
   CallToolResult; any other falls through to the generic handler as before
   (delivery_uncertain, conservative since the checkpoint was claimed).

Tests: a /mcp end-to-end test with a real SDK upstream answering an
unparseable envelope (readable tool result, receipt, one debit, no redispatch
on replay), the existing test that upstream_returned_error stays a bare error,
and a real-SDK test that an unsendable request is not a rejected response. The
fake-session test now raises a genuine CallToolResult validation error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs(mcp): record the endpoint decision, re-pin stale citations, re-measure gateway overhead

Follow-up to 0698168.

Endpoint decision. POST /mcp/messages and the REST invoke route are the live
governed path in the supported pilot. POST /mcp ships off
(ENABLE_STANDARD_MCP_ENDPOINT defaults to false) and the production runbook
keeps it off, because it mints a short-lived single-tool permit and generates
an idempotency key on the caller's behalf. README.md and SECURITY_LIMITATIONS.md
no longer call it the supported or preferred path today. The posture doc
records the decision instead of describing a contradiction, and the overhead
page no longer calls /mcp/messages "not the supported endpoint".
docs/deploy-railway.md and docs/mcp-registry-submission.md were already
correct and are untouched.

Citations. The file:line citations in docs/mcp-security-posture.md and
docs/gateway-overhead.md were written against a base more than a thousand
commits older. Of 17, 15 had moved, one SDK range was a line short and
mcp.py:15 was still right. Each is re-pinned and the claim beside it re-read.
Corrected: idempotency_key_required applies to every governed call (including
any call in strict trust mode), not only permit-bound ones; a signed denied
receipt is issued only when the permit names the caller's wallet; the SDK
validation range is 412-448; exit-and-export.md said no OpenTelemetry
instrumentation exists, but optional tracing (OTEL_TRACING_ENABLED, default
off) does; the poisoning-test docstring named one call site of
register_configured_upstream_mcp where scripts/failure_lab.py is a second.

Response classification docs. failure-semantics and the posture page now state
the caveat that a body that is not a JSON-RPC message at all (non-JSON text,
jsonrpc 1.0, neither result nor error, unknown request id) still ends as
delivery_uncertain after the call timeout, each observed against a scripted
transport; that with the reconcile worker enabled response_rejected is
terminal and never probed or refunded; and that /mcp now returns
response_rejected as a readable tool result. The CHANGELOG no longer says
precedence is unchanged for the outer ValidationError branch, which gained the
guard, and describes the transport behaviour accurately. TROUBLESHOOTING gains
a response_rejected section and authority-required-flow names the renamed
helper.

Gateway overhead re-measured twice on this base (commit 0698168, git_dirty
false, load average recorded): first call p50 29.5-30.9 ms, replay p50
5.4-5.6 ms (earlier runs 29.5-31.9 and 4.4-4.7). Results in
docs/research/gateway-overhead-2026-10-23/. The public site's figure is not
touched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(mcp): one parse-failure rule for both handlers; correct posture claims; finish the endpoint wording

Second review of 0698168 and 6638679.

Code. The outer ValidationError / JSONDecodeError branch of _call_tool_once
still answered response_rejected for any such error after dispatch, so a
session that does not wrap exceptions would again label the request-build
error response_rejected. Both handlers now use one rule,
_is_unparseable_response: after dispatch a CallToolResult validation error or a
JSON decode error is response_rejected, and any other validation error is
delivery_uncertain.

Tests. A non-wrapping session double covers both directions. The /mcp
guided-result test now covers an oversized result and a bearer echo as well as
an unparseable envelope, and asserts nothing the upstream wrote is relayed. A
spy test pins the gateway outcome recorded on /mcp (failed for a rejected
response, unknown for an undelivered one); a mutation that always marked
"unknown" used to pass every test. A new test pins that POST /mcp honours a
permit the caller names, enforcing forbidden_fields with a signed denied
receipt, and mints an unconstrained permit only when the reference is omitted.
The keyless test asserts the generated key is readable in the receipt as the
forwarded key, and the comment in mcp_standard.py that said it is never
returned is corrected.

Docs, claims that did not hold. The posture page said operator permit
constraints apply only on /mcp/messages and REST (not so when the caller names a
permit), that there is no KMS and no row-level security (both exist as opt-ins,
default off), and listed governed_call causes incompletely. It now also notes
that the refunded upstream_returned_error relays the upstream's result payload
in error.data on all three surfaces. exit-and-export.md no longer says traces
are not produced and says the only mounted DELETE route that deletes anything
is for API keys. gateway-overhead.md quotes only the first-call p99 and max and
records the kernel release rather than a macOS version the result files do not
contain. The poisoning-test docstring lists all three call sites.

Endpoint wording, leftovers. The REST invoke route's OpenAPI description no
longer tells every integration to call POST /mcp; docs/openapi.json is
regenerated with scripts/export_openapi.py and differs by that one line. The
Cursor plugin README and skill say the standard endpoint ships off. README
names the REST invoke route as supported; authority-required-flow and two test
docstrings no longer present /mcp as the surface integrations are told to use.
The plugin's mcp.json, which targets /mcp on the first-party host, is
deliberately unchanged: that is a product decision.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs(mcp): correct the idempotency-key and delete claims; say /mcp/messages is the live path

Third review of 67d56b3. Text and served text only; no behavior change.

Refuted claims. The posture page and CHANGELOG said an action-bound call
without an idempotency key is refused with idempotency_key_required on
/mcp/messages and REST. It is not: mcp.py replaces the key with a
server-derived act1- key (action_permits.py) before that check, and a call that
names no permit is refused earlier as permit_required. The refusal is for a
call that names a permit and sends no key. exit-and-export.md and the CHANGELOG
said the API-key DELETE route is the one that deletes something; it only
revokes (status, revoked_at, revoke_reason) and keeps the row, so in the
default configuration no mounted DELETE route deletes anything.

Served text. The /mcp/messages OpenAPI description called it a legacy endpoint
"kept for existing clients and the local proof scripts" and named only /mcp,
which ships off, as an alternative. It now says it is the live governed path in
the supported pilot and why it is still marked deprecated (a project transport
that does not implement the standard MCP initialization lifecycle, to be
replaced by /mcp once an operator enables it). The REST invoke description and
site/docs-src/http-api.md get the same fix. The description keeps its line
count so the cited mcp.py lines do not move. docs/openapi.json is regenerated
under requirements.lock and differs by those two descriptions. The
machine-read discovery labels (legacy_project_transport,
legacy_project_json_rpc) are deliberately unchanged.

Smaller fixes. The _call_tool_once docstring now says only a CallToolResult
parse failure is rejected, while a body that is not a JSON-RPC message is
logged by the SDK transport, never raised, and ends delivery_uncertain at the
timeout. On REST the relayed upstream text is detail.upstream_result in the
502 body. The CHANGELOG lists the other causes of response_rejected, which
/mcp matches on by reason. Two comments that contradicted corrected claims are
fixed (mcpContext.permit_id is read; the keyless key is readable in the
receipt). failure-semantics cites a test that exercises /mcp/messages and says
the REST status is untested. The Cursor plugin READMEs name REST invoke as a
live path and carry the ships-off caveat; mcp.json is untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs(mcp): scope the act1- key and idempotency_key_required claims precisely

Fourth review of 93aee1a. Text only; no .py file differs from that commit.

The posture page said the server derives an act1- key "from the permit and
payload". The digest (action_permits.py) covers only the action permit's
identity: contract version, permit id, subject wallet, public tool id and
upstream binding hash. The permit's action_payload_hash goes into the request
payload, not the key. It now says so.

The same row said a governed call that names no permit is refused earlier as
permit_required. Two checks answer first: an action-bound tool called without
an action permit is action_permit_required (mcp.py:1672-1676), and an unknown
tool is "Tool not found" (mcp.py:1740-1750). Both are named, with the lines.

An older CHANGELOG entry and failure-semantics.md still said
idempotency_key_required applies to every governed call, including any call in
strict trust mode. Both now scope it to a call that names a permit and sends no
key, matching the posture row. exit-and-export.md called the API-key route "the
one mounted DELETE route" in a sentence that also names two DELETE stubs; it
now says the API-key route only revokes and the stubs refuse, so no default
mounted DELETE route deletes anything.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant