This repository was archived by the owner on Oct 24, 2026. It is now read-only.
Repository navigation
fix(mcp): an unparseable upstream envelope is response_rejected; /mcp guides on rejected responses; correct posture claims - #854
Merged
PetrefiedThunder merged 6 commits intoOct 24, 2026
Conversation
…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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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
merged commit Oct 24, 2026
8a55029
into
claude/objective-knuth-d05d5a
2 checks passed
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tomainwhen #853 merges. If #853 is squash-merged, expect this PR to show conflicts or extra files until it is rebased ontomain; 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_rejectedA
tools/callresponse that the upstream sent but whose envelope the SDK cannot parse (contenta string,isErrornot a bool, and similar) ended asdelivery_uncertain(504 / -32005, counted by the circuit breaker) through a real SDK session._call_tool_onceintendsresponse_rejected.Cause: the SDK's anyio task group rewraps anything raised inside the
async with self._session()block in anExceptionGroup, so the adapter'sValidationErrorbranch never matched and the generic handler classified the call bydispatch_startedalone. Tests that fake the session do not enter the task group, which is why they passed.Fix:
_call_tool_oncerecords the rejection at thecall_toolawait, where the SDK raisesValidationErroronly after the upstream's JSON-RPC result has arrived. The call ends asresponse_rejectedwith codeupstream_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 answeringtools/call) and a malformedinitializeanswer keep their outcomes.2.
/mcptells the model not to resend a rejected responseapp/routers/mcp_standard.pyreturned the do-not-resend tool result only fordelivery_uncertain.response_rejectedis 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_rejectednow returns the same guided result asdelivery_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 statusunknownand reasonresponse_rejected. Refunded failures stay bare errors./mcp/messagesand REST are unchanged. Nothing the upstream wrote is relayed.3. One parse-failure rule for both handlers
The ladder's
ValidationErrorhandlers could label a request-build error (for exampleargumentswith a non-string key: the claim is committed and notools/callreaches the upstream) asresponse_rejected, which asserts the upstream answered. Both handlers now use_is_unparseable_response: after dispatch, aCallToolResultvalidation error or a JSON decode error isresponse_rejected, and any other validation error isdelivery_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.mdand READMEs. The substantive ones:POST /mcp/messagesand the REST invoke route are the live governed path in the supported pilot.POST /mcpships off (ENABLE_STANDARD_MCP_ENDPOINTdefaults to false) and the runbook keeps it off.idempotency_key_requiredis a refusal for a call that names a permit and sends no key, not for every governed call.mcp.pyreplaces a missing key with a server-derivedact1-key before that check. The digest covers the action permit's identity only, not the payload.DELETEroute only revokes (status,revoked_at,revoke_reason) and keeps the row. In the default configuration no mountedDELETEroute deletes anything.OTEL_TRACING_ENABLED, default off).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 endsdelivery_uncertainafter the call timeout.docs/research/gateway-overhead-2026-10-23/. The public site's figure is not touched.Behavior changes
response_rejectedcauses.POST /mcpreturnsresponse_rejectedas anisErrortool 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.jsonis regenerated withscripts/export_openapi.py. It differs by the REST invoke and/mcp/messagesdescriptions only: no schema or path changes.Deliberately unchanged
mcp.json, which targets/mcpon the first-party host. That is a product decision.legacy_project_transportandlegacy_project_json_rpc.Tests
/mcpend 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./mcp(failedfor a rejected response,unknownfor an undelivered one).POST /mcphonours a permit the caller names, enforcingforbidden_fieldswith a signed denied receipt.response_rejectedreceipt, 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
.pyfile differs between93aee1aband8c2b4428.Not covered
delivery_uncertain.Open owner decisions (not made here)
mcp.jsonURL.🤖 Generated with Claude Code
Note
High Risk
Changes charged-call outcome semantics, circuit-breaker counting, and default-off
POST /mcpclient-visible errors; mistakes could cause double charges or unsafe retries.Overview
Fixes misclassification of upstream
tools/callresponses the MCP SDK cannot parse: they now finalize as charged, terminalresponse_rejected(upstream_response_invalid, 502 /-32006) instead ofdelivery_uncertain. The adapter recordsCallToolResultvalidation failures at thecall_toolawait (before the SDK task group wraps them inExceptionGroup) and applies_is_unparseable_responseconsistently 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_rejectedis returned likedelivery_uncertain: anisErrortool result with do-not-resend guidance and outcome metadata (refunded-32006failures remain JSON-RPC errors). Malformed envelopes no longer feed the circuit breaker.Docs and API copy record that
/mcp/messagesand REST invoke are the supported pilot paths (POST /mcpoff 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.