Skip to content

Use standard JSON-RPC error codes and scrub leaked error text - #6066

Merged
JAORMX merged 2 commits into
mainfrom
fix-6039-jsonrpc-codes
Jul 28, 2026
Merged

Use standard JSON-RPC error codes and scrub leaked error text#6066
JAORMX merged 2 commits into
mainfrom
fix-6039-jsonrpc-codes

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Two consistency problems in the same handful of denial/error functions, both found while fixing #5950 and deliberately left out of that PR because neither is the envelope bug.

1. HTTP statuses used as JSON-RPC error codes. Three paths passed the HTTP status straight through as error.code: pkg/authz/response_filter.go emitted a literal 500, and both webhook middlewares emitted whatever status they were called with — 413, 422, or 500. Those values are legal, since they sit outside the reserved -32768..-32000 range, but they are not the codes the draft MCP spec designates for general protocol failures ("MCP uses the standard JSON-RPC 2.0 error codes (-32700, -32600 to -32603) for general protocol failures"). A client that branches on error.code cannot recognize 500 as internal-error or 422 as a denial.

The status and the code are independent dimensions, so mcp.JSONRPCCodeForStatus is now the single place the mapping lives: 413 → -32600 (an oversized body is a malformed request), 500 → -32603, and 422 → JSONRPCCodeDenied. That last one matters: the mutating path relays a webhook's HTTP 422 always-deny response downstream, while the validating path relays the identical condition as 403 — so emitting 422 as the code made one denial look unlike every other denial, defeating the stated purpose of the shared constant.

Deriving the code inside sendErrorResponse leaves all 13 call sites untouched. An explicit second parameter was considered and rejected: each status here already corresponds to exactly one failure class with exactly one correct code, so passing a redundant constant at 13 sites is 13 more places to get it wrong.

403 is unchanged. It is the one case where status and code legitimately coincide, deliberately outside the reserved range so it never collides with an SDK-generated code. It stays asserted across pkg/vmcp/server/*_test.go and documented at docs/arch/10-virtual-mcp-architecture.md:631.

2. Denial paths leaked internal error text. handleUnauthorized used err.Error() as the client-visible message, so a Cedar evaluation error and a default-deny message disclosing "(not configured for authorization)" both reached the caller; writeErrorResponse sent "Error filtering response: %v", where the error can originate in policy evaluation and name tools. Meanwhile pkg/webhook/validating/middleware.go does the exact opposite two directories over, with the comment "Prevent information leaks by ignoring the webhook's message" — so the tree contradicted itself in adjacent files.

Standardized on scrub-and-log, following writeModernListError in pkg/vmcp/server/modern_dispatch.go. The client-visible string is unchanged from what a clean deny already produced, so an authorizer error and a policy deny are now byte-identical to the caller rather than distinguishable. Scoping this honestly, as the issue does: the recipient is already authenticated, just not authorized, and what leaked was policy-engine structure — not credentials and not another user's data. This is a hardening and consistency item, not a vulnerability.

Fixes #6039

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

task test passed with exit code 0, plus scoped go test ./pkg/mcp/... ./pkg/authz/... ./pkg/webhook/... ./pkg/runner/... green on every touched package.

One caveat worth stating rather than hiding: task test's command is go test ... | gotestfmt -hide "all", with no pipefail set, so its exit code is a weaker signal than it appears and the run produces no per-package output at all. The scoped runs are the real per-package evidence.

Every new assertion was proven to be a real regression guard by reverting the production line it covers, confirming the test fails, then restoring. Two worth calling out:

  • Reverting sendErrorResponse to int64(statusCode) makes the 422 test fail with expected: 403, actual: 422 — that assertion is what pins "a denial looks like every other denial".
  • Reverting authorizeAndServe's err != nil || disjunct makes the new allowed=true cases fail with the next handler having been called and a 200 returned. Nothing in the suite covered that before (see reviewer notes below).

Linting not run locally; leaving it to CI.

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Changes

File Change
pkg/mcp/errors.go New JSONRPCCodeForStatus; records why JSONRPCCodeDenied is deliberately untyped
pkg/mcp/revision.go CodeInternalError = -32603 added to the existing standard-code block; two comments resynced
pkg/webhook/validating/middleware.go sendErrorResponse maps status → code; encode-failure fallback body fixed
pkg/webhook/mutating/middleware.go Same (10 call sites, all untouched)
pkg/authz/response_filter.go writeErrorResponse: 500CodeInternalError, message scrubbed + logged; processSSEResponse no longer interpolates the raw body
pkg/authz/middleware.go handleUnauthorized always sends "Unauthorized" and logs the error; unknown-method deny logs at Debug from the call site
pkg/mcp/errors_test.go New table-driven mapping test incl. the default case
pkg/webhook/*/middleware_test.go 413 assertions updated; new 422→403, 500→-32603, and a validating-side 500 path that had no coverage
pkg/authz/middleware_test.go New authorizer-error scrub test; new allowed=true fail-closed case
pkg/authz/response_filter_test.go Disguised-result test now pins the mapped code and the generic message

Does this introduce a user-facing change?

Yes, and it is client-visible on two axes.

Error codes. Denial and error bodies from the authorization middleware, the response filter, and both webhook middlewares now carry standard JSON-RPC codes instead of HTTP statuses: 500-32603, 413-32600, 422403. Clients that branch on error.code can now recognize these as internal-error / invalid-request / denied. 403 is unchanged. No HTTP status changes, so anything keying off the status — including audit outcomes, which are derived from the status and not the code — behaves identically.

Error messages. Authorization denials now always read Unauthorized, and response-filter failures read internal error, instead of carrying the underlying error text. Operators lose nothing: the full error is logged server-side with more context than the client ever had. Anyone who was scraping the old message text for diagnostics will need to read the logs instead.

Implementation plan

Approved implementation plan

Implemented in three delegated steps (Sonnet worker, Opus reviewer per step, iterating until the reviewer had no blocking findings), landing as two commits.

Step 0 — branch setup. Branch off fix-nonconformant-jsonrpc-denials (#6055), which rewrites these exact four functions to encode via jsonrpc2.EncodeMessage. #6055 also de-shadowed the err parameter in handleUnauthorized, which is what makes the original error available to log in step 3.

Step 1 — the mapping. Reuse the existing standard-code family in pkg/mcp/revision.go (which already exports CodeInvalidRequest = -32600) rather than inventing a new home; add CodeInternalError alongside it and JSONRPCCodeForStatus next to JSONRPCCodeDenied. Acceptance: no constant duplicated, default fails safe to CodeInternalError, no new import beyond net/http.

Step 2 — apply at the call sites. Derive the code inside both sendErrorResponse functions and use CodeInternalError in writeErrorResponse; fix the encode-failure fallback bodies, which spliced the raw status into the code field and would otherwise reintroduce the bug they stand in for. Acceptance: no HTTP status changed anywhere, the 403 assertions pass unmodified, and the 422 assertion is a proven regression guard. → commit 1.

Step 3 — scrub the error text. handleUnauthorized logs and always sends "Unauthorized"; writeErrorResponse logs with the method and sends "internal error". Fixing the shared handler is the root-cause fix — it covers both callers at once. Acceptance: the message string stays exactly "Unauthorized" so pkg/runner/authz_audit_integration_test.go passes unmodified, fail-closed semantics unchanged, and no scrubbed error is silently dropped. → commit 2.

Deliberately out of scope, each tracked: #6037 (SSE data: prefix), #6038 (three hand-built envelopes still emitting "id":null).

Special notes for reviewers

Was stacked on #6055; now standalone. This was originally built on fix-nonconformant-jsonrpc-denials because parts of it — notably the encode-failure fallback bodies — only exist after that refactor. #6055 has since merged (9b0d83af0), so this is rebased onto main and the diff is just its own two commits.

Three things the review pass caught that are worth knowing about, because two of them were defects in this PR's own tests:

  1. A test that did not test what it claimed. The first version of TestHandleUnauthorizedScrubsAuthorizerError used stubAuthorizer{allowed: false, err: sensitiveErr}. authorizeAndServe's condition is err != nil || !authorized, so allowed: false takes the branch via the !authorized disjunct — every assertion passed identically with err: nil, which made the NotContains vacuous. Now allowed: true, so the denial can only come from the error. This also closed a real coverage gap: nothing in the suite pinned that an authorizer returning (true, err) fails closed, so dropping the err != nil disjunct would have served an erroring authorizer with the whole suite still green.

  2. A sibling leak the scrub initially missed. processSSEResponse interpolated the entire buffered upstream body — for a tools/list, the full pre-Cedar tool list — into an error that is logged upstream by FlushAndFilter's caller. Not client-visible, but it routed the exact payload this filter exists to scrub into an unbounded, caller-influenceable log line. Now reports the length only.

  3. Log-level conflation. handleUnauthorized's slog.Warn served two very different events: the unknown-method default deny (expected traffic from any newer-spec client hitting tasks/list, sampling/createMessage, …) and an actual authorizer failure. Since parsedRequest.Method is unbounded caller-controlled input, that was one attacker-chosen WARN line per request. The routine deny now logs at Debug from the call site — matching how this package already logs per-item denials, and how the webhook middlewares split Info for denials from Error for operational failures — leaving Warn for genuine authorizer errors.

One scoping caveat on "standardize", so nobody reads more into it than is there. This aligns pkg/authz with the nearest precedent, not with a tree-wide rule. writeModernListError scrubs because its errors carry aggregation and routing plumbing that security.md forbids exposing — the same category as the policy-engine detail pkg/authz was leaking, which is why it is the right precedent here. Its sibling writeModernDispatchError (pkg/vmcp/server/modern_dispatch.go:618-623) deliberately does the opposite, relaying err.Error() verbatim, on the documented grounds that the SDK path already exposes the raw text for the identical failure. That exception survives this PR by design. So the split that remains is a reasoned one between error categories, not the adjacent-files contradiction this PR removes.

A note for whoever touches the logging next. handleUnauthorized's doc comment now warns that the logged error can embed decoded JWT claim values: preprocessClaims puts every claim into the Cedar evaluation context, and cedar-go's decimal()/ip()/datetime() constructors echo the offending value into Diagnostic.Errors. That directly contradicts the deliberate "claim keys only — never claim values, which hold user PII" rule in authorizers/cedar/core.go. Nothing leaks today, but this payload should not be promoted to a structured field that ships to an aggregator.

A same-defect sibling remains, tracked as a follow-up. processSSEResponse here and processEventStream in pkg/mcp/tool_filter.go carry the byte-identical fmt.Errorf("unsupported separator: %s", <whole buffer>) — splicing the full pre-filter payload (the unfiltered tool list, for a tools/list) into an error that is logged upstream. Neither is client-visible, so neither is a leak in the scrub's sense, but both route the exact payload the filter exists to scrub into an unbounded, caller-influenceable log line. This PR fixes the one in its own blast radius; the tool_filter.go sibling is fixed in a follow-up rather than retro-fitted here.

Two items deliberately banked, not fixed here:

  • pkg/authz/middleware.go:330 — a passThroughTools entry with an empty or non-string tool_name is served with no authorization check at all, relying on the optimizer decorator to gate downstream. Pre-existing, deliberate, and pinned by an existing test, but it deserves its own issue confirming it against the decorator's actual behavior.
  • convErr in handleUnauthorized and requestID()'s error in response_filter.go are still swallowed. Both are commented, so the go-style rule is satisfied, but the consequence is a client-visible symptom — an error response with no id, uncorrelatable — with zero server-side trace. Those lines come from Emit conformant JSON-RPC on denial and error paths #6055, so they belong there or in a follow-up.

Why no shared envelope helper, in case it comes up: #6055 already argued this and it holds here too. The tree has several hand-rolled envelope builders; a shared one absorbs none of them without an options struct, and an any-typed id parameter is exactly how a caller reintroduces the id bug #6055 fixed. The greppable rule is the cheaper guard.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.26087% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.25%. Comparing base (9b0d83a) to head (bf310dd).

Files with missing lines Patch % Lines
pkg/authz/response_filter.go 50.00% 2 Missing ⚠️
pkg/authz/middleware.go 80.00% 1 Missing ⚠️
pkg/webhook/mutating/middleware.go 66.66% 1 Missing ⚠️
pkg/webhook/validating/middleware.go 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6066      +/-   ##
==========================================
- Coverage   72.25%   72.25%   -0.01%     
==========================================
  Files         724      725       +1     
  Lines       75348    75358      +10     
==========================================
+ Hits        54445    54448       +3     
- Misses      17022    17030       +8     
+ Partials     3881     3880       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

JAORMX
JAORMX previously approved these changes Jul 28, 2026
@jhrozek
jhrozek force-pushed the fix-nonconformant-jsonrpc-denials branch from 246aa7c to 0e7fc58 Compare July 28, 2026 06:39
@jhrozek
jhrozek force-pushed the fix-6039-jsonrpc-codes branch from 81a6957 to fb593e9 Compare July 28, 2026 06:54
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 28, 2026
Base automatically changed from fix-nonconformant-jsonrpc-denials to main July 28, 2026 07:13
@JAORMX
JAORMX dismissed their stale review July 28, 2026 07:13

The base branch was changed.

jhrozek and others added 2 commits July 28, 2026 09:24
Three error paths passed the HTTP status straight through as the
JSON-RPC error.code: the authz response filter emitted a literal 500,
and both webhook middlewares emitted whatever status they were called
with (413, 422, or 500). Those values are legal, since they sit outside
the reserved -32768..-32000 range, but they are not the codes the draft
MCP spec designates for general protocol failures, so a client that
branches on error.code cannot recognize 500 as internal-error or 422 as
a denial.

The HTTP status and the JSON-RPC code are independent dimensions. Add
mcp.JSONRPCCodeForStatus as the single place the mapping lives: 413
becomes -32600 (an oversized body is a malformed request), 500 becomes
-32603, and 422 becomes JSONRPCCodeDenied, because the mutating path
relays a webhook's 422 always-deny response downstream while the
validating path relays the identical condition as 403 -- emitting 422
as the code would make one denial look unlike every other denial.

403 is unchanged. It is the one case where status and code legitimately
coincide, deliberately outside the reserved range so it never collides
with an SDK-generated code.

Deriving the code inside sendErrorResponse leaves all 13 call sites
untouched. The encode-failure fallback bodies are fixed too; they
spliced the raw status into the code field, so leaving them would
reintroduce the bug they stand in for.

No HTTP status changes, so audit outcomes -- which key off the status,
not the code -- are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pkg/authz forwarded internal error text verbatim to callers on three
paths: the Cedar authorizer's evaluation error, a default-deny message
disclosing that a method is "not configured for authorization", and the
response filter's error, which can originate in policy evaluation and
name tools. Meanwhile pkg/webhook/validating deliberately does the
opposite, with the comment "Prevent information leaks by ignoring the
webhook's message", so the tree contradicted itself in adjacent files.

Standardize on scrub-and-log, following writeModernListError in
pkg/vmcp/server: handleUnauthorized now always sends "Unauthorized" and
logs the error, and writeErrorResponse sends "internal error" with the
method as a log field. Fixing the shared handler covers both callers at
once. The client-visible string is unchanged from what a clean deny
already produced, so an authorizer error and a policy deny are now
byte-identical to the caller rather than distinguishable.

processSSEResponse's unsupported-separator error interpolated the whole
buffered upstream body -- the unfiltered tool list, for a tools/list --
into an error that is logged upstream. It now reports the length only.

The unknown-method deny logs at Debug from the call site rather than
Warn: it is expected traffic from a newer-spec client, and the method
name is unbounded caller-controlled input, so routing it to Warn let a
caller dilute that stream at will.

Fail-closed semantics are unchanged; only the text the caller sees does.
The authorizer-error tests now use allowed=true so the denial can only
come from the error, which also pins that an authorizer reporting
allowed alongside an error still fails closed -- nothing covered that
before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jhrozek
jhrozek force-pushed the fix-6039-jsonrpc-codes branch from fb593e9 to bf310dd Compare July 28, 2026 07:26
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 28, 2026
@JAORMX
JAORMX merged commit 4a61bd1 into main Jul 28, 2026
48 checks passed
@JAORMX
JAORMX deleted the fix-6039-jsonrpc-codes branch July 28, 2026 07:44
@github-actions github-actions Bot added size/S Small PR: 100-299 lines changed and removed size/S Small PR: 100-299 lines changed labels Jul 28, 2026
jhrozek added a commit that referenced this pull request Jul 28, 2026
processEventStream reported an unrecognized line separator by
interpolating the entire buffer into the error string. That buffer is the
full pre-filter payload -- for a tools/list, the unfiltered tool list --
and the error is logged upstream, so a backend response with no
recognized separator produced one unbounded log line containing the very
payload the filter exists to scrub. Report the length instead, which is
the only diagnostic value the message carried.

This is the byte-identical sibling of the case fixed in
pkg/authz/response_filter.go by #6066. Fixing one and not the other was
an oversight, not a scoping decision.

Also correct a comment that contradicted its own function: it said the
authorizer error "must never be promoted to a structured field routed to
an aggregator beyond this log line" while the next statement logs it as
exactly such a field. The intent was to forbid copying it into further
log lines, not to forbid the one that is there.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
JAORMX added a commit that referenced this pull request Jul 28, 2026
Three HTTP denial paths each carry a private copy of the same block:
encode a *jsonrpc2.Response via jsonrpc2.EncodeMessage before writing
any header, fall back to a hardcoded conformant body on the unreachable
encode failure, then set Content-Type, write status, write body. That
block exists because of #5950 — encoding/json on a jsonrpc2 type
reflects Go-capitalized keys, drops the mandatory "jsonrpc":"2.0" tag,
and renders every id as {} — so the only thing preventing a fourth
hand-rolled copy from reintroducing that bug has been a greppable
convention.

#6055's review rejected a map-building shared helper, because an
any-typed id parameter is exactly how a caller reintroduces the {} id
bug, but noted a narrower helper taking a jsonrpc2 message avoids the
objection. Add exactly that: mcp.EncodeJSONRPCError and
mcp.WriteJSONRPCError take a typed *jsonrpc2.Response, so no id can
reach them untyped.

Scope is deliberately three sites, not four. The fourth carrier of this
block, pkg/authz/response_filter.go, is being rewritten by #6087 and
again by #6088; collapsing it here would guarantee a conflict and
duplicate that work, so it keeps its private copy for now and can adopt
the helper once those land.

Behavior is unchanged except the per-site fallback bodies, which were
unreachable and now uniformly report -32603 rather than echoing a code
whose integrity is unknown once encoding has failed. Each site keeps its
own status and its own code via #6066's mcp.JSONRPCCodeForStatus.

The 'never json.Marshal a jsonrpc2 type' convention is also recorded in
.claude/rules/go-style.md. A lint gate was considered and deliberately
not added: forbidigo matches call text and cannot see argument types, so
it cannot distinguish marshaling a jsonrpc2 value from any other
json.Marshal call, and wiring up gocritic/ruleguard for one rule is not
worth the infrastructure. The helper plus the recorded rule is the
enforcement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
JAORMX added a commit that referenced this pull request Jul 28, 2026
Three HTTP denial paths each carry a private copy of the same block:
encode a *jsonrpc2.Response via jsonrpc2.EncodeMessage before writing
any header, fall back to a hardcoded conformant body on the unreachable
encode failure, then set Content-Type, write status, write body. That
block exists because of #5950 — encoding/json on a jsonrpc2 type
reflects Go-capitalized keys, drops the mandatory "jsonrpc":"2.0" tag,
and renders every id as {} — so the only thing preventing a fourth
hand-rolled copy from reintroducing that bug has been a greppable
convention.

#6055's review rejected a map-building shared helper, because an
any-typed id parameter is exactly how a caller reintroduces the {} id
bug, but noted a narrower helper taking a jsonrpc2 message avoids the
objection. Add exactly that: mcp.EncodeJSONRPCError and
mcp.WriteJSONRPCError take a typed *jsonrpc2.Response, so no id can
reach them untyped.

Scope is deliberately three sites, not four. The fourth carrier of this
block, pkg/authz/response_filter.go, is being rewritten by #6087 and
again by #6088; collapsing it here would guarantee a conflict and
duplicate that work, so it keeps its private copy for now and can adopt
the helper once those land.

Behavior is unchanged except the per-site fallback bodies, which were
unreachable and now uniformly report -32603 rather than echoing a code
whose integrity is unknown once encoding has failed. Each site keeps its
own status and its own code via #6066's mcp.JSONRPCCodeForStatus.

The 'never json.Marshal a jsonrpc2 type' convention is also recorded in
.claude/rules/go-style.md. A lint gate was considered and deliberately
not added: forbidigo matches call text and cannot see argument types, so
it cannot distinguish marshaling a jsonrpc2 value from any other
json.Marshal call, and wiring up gocritic/ruleguard for one rule is not
worth the infrastructure. The helper plus the recorded rule is the
enforcement.


Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
JAORMX added a commit that referenced this pull request Jul 28, 2026
Review (jhrozek) found the seam promising more than it enforced.
Extraction is now fail-closed on three gates the design already
stated but the code did not check: the method allow-list (only
tools/call, resources/read, prompts/get may carry a round), strict
payload decode (a wrong-typed inputRequests or requestState is a
schema violation, not an empty round), and server requirement 6
(an envelope with nothing to fulfill and nothing to echo must not
become a retry-forever round). RequestState becomes *string because
client requirement 2 makes absent-vs-present-and-empty an observable
distinction the retry must preserve.

The carrier and extractor move to pkg/vmcp beside InputRequiredResult
with exported fields, so slice 2's server-side rendering never has to
import the concrete HTTP client and mock-based consumers can construct
the error. Classification and message text are unchanged: the typed
error still unwraps to the client's sentinel and renders byte-
identically — except that a resultType beyond 64 bytes is now
truncated to a prefix plus length, the #6079/#6066 rule, since the
text reaches the downstream client.

Also per review: modernResultTypeInputRequired constant beside the
existing complete constant, the unrecognized-resultType comment cites
the page's MUST rather than the SEP's SHOULD, the sentinel's comment
explains why its message is frozen instead of claiming MRTR is
deferred, the message-pinning test hardcodes the expected string so
test and production cannot drift together, and the verbatim-relay
test asserts raw bytes (assert.Equal) as its name promises.

Part of #6059.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
JAORMX added a commit that referenced this pull request Jul 28, 2026
* Design MRTR for the vMCP Modern path

The 2026-07-28 revision removes server-initiated requests outright and
replaces them with client-polled Multi Round-Trip Requests (SEP-2322).
vMCP has no MRTR implementation: elicitation and sampling forward only
over Legacy sessions, and the Modern egress shim declares empty
clientCapabilities, so a compliant Modern backend that needs input
answers -32021 and the call surfaces as an opaque protocol error. With
the Modern dispatch kill-switch being removed (#5959/#6033), that gap
becomes the served behavior.

Add the design the closed-as-not-planned #5759 asked for, as its own
document so it composes with the in-flight edits to doc 10 (#6050,
#6051). Decisions, grounded in the spec text read one day before the
revision's finalization and in RFC-0083:

- Modern-client/Modern-backend rounds are a stateless pass-through:
  per-request capability mirroring, verbatim inputRequests/requestState
  relay, deterministic re-routing of the retry. vMCP adds no state, so
  no durable store is triggered.
- Legacy-client/Modern-backend rounds bridge in-request: vMCP fulfills
  the backend's inputRequests through the existing
  ElicitationRequester/SamplingRequester seams and retries the backend
  call itself, bounded, on one pod — the same bridge go-sdk's own
  serverMultiRoundTripMiddleware performs for its handlers.
- Modern-client/Legacy-backend stays deliberately unbridged, per the
  costed rejection recorded in doc 10; the Tasks extension is the
  sanctioned stateful path.
- Only composite-workflow suspend/resume needs durable state, and it
  takes RFC-0083 D5 verbatim: an opaque >=128-bit handle as the
  client-visible requestState, WorkflowStatus.Owner bound to plaintext
  (iss, sub) via session/binding.Format, owner-checked resume, TTL,
  audit redaction, Redis store behind a core.Config seam.
- Sampling gets no feature work: SEP-2577 deprecates it, and the relay
  is type-agnostic, so it transits for existing counterparts only.
  Roots additionally has no existing vMCP counterpart and is refused on
  the bridge cell.

The gap list names what the pinned go-sdk/mcpcompat cannot express
(mcpcompat drops inputRequests/requestState in both directions; the
SDK's MRTR internals are unexported), phrased for toolhive-core.

Refs #5743, #5759, #6018

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Surface Modern input_required rounds as typed values

An MCP 2026-07-28 backend that needs more input answers with a
resultType:"input_required" envelope (SEP-2322). The Modern egress shim
collapsed that into the opaque errModernInputRequired sentinel,
discarding the inputRequests and requestState payload — so no upper
layer could ever relay or fulfill a round, and the MRTR work designed
in docs/arch/16-vmcp-mrtr.md had no seam to build on.

Decode the envelope into a domain vmcp.InputRequiredResult carried by a
typed error. The values stay opaque json.RawMessage (the pass-through
relay must forward them verbatim, never reinterpret them) with a
Methods() probe for the capability gate; the error unwraps to
errModernInputRequired with a byte-identical message, so revision
classification (probeRevision) and every client-visible behavior are
unchanged — capabilities are still declared empty, so a compliant
backend cannot yet send a round at all. InputRequiredFromError is the
single branch point the ingress relay and the Legacy-client bridge
(slices 2-4 of the design) will consume.

Confined to pkg/vmcp and pkg/vmcp/client, which none of the in-flight
Modern-path PRs (#6050, #6033, #6051) touch.

Refs #5743, #5759

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Flag the MRTR design's draft-only spec dependencies

The 2026-07-28 revision finalizes one day after this design was written
against schema/draft. The schema types and SEPs it cites are already
Final, but four load-bearing points rest on the draft spec page's text,
which refines the Final SEP: the three-method table (the SEP also
allowed GetTaskPayloadRequest), the capability-gating MUST NOT, the
at-least-one-field requirement, and the requestState security language.
Name them explicitly with the design's exposure to each, so the
re-verification against the final cut is a checklist rather than a
re-read.

Refs #5759

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Reconcile the MRTR design with merged #6061

PR #6061 landed the two-path mid-call capability-refusal contract this
design's sequencing anticipated: -32021 with data.requiredCapabilities
at HTTP 200 (documented deviation, go-sdk#1117) for an undeclared
capability, an explicit -32603 naming SEP-2322 for a declared one.
Reconcile rather than duplicate: the contract is permanent for the
Modern-client/Legacy-backend cell (the only cell its refusal recorder
can fire on — the SDK adapters are the Legacy-session forwarding
seams), superseded by capability mirroring only where the backend is
Modern. Name writeModernCallFailure as slice 2's rendering hook —
the input_required branch and the refusal branch cannot co-occur —
and note the declared-case message needs rescoping when slice 3 lands.

Also record the finalization re-check: on 2026-07-28 the revision is
still not cut (no schema/2026-07-28 directory, /specification/2026-07-28
pages 404, schema/draft byte-identical to the copy designed against),
so the draft-only-dependency checklist stays pending.

Refs #5759, #6061

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Regenerate CRD reference docs

task crdref-gen output shifts by two trailing blank lines once this
branch's new exported pkg/vmcp types are in the generator's scanned set.
Verified the drift is this branch's consequence, not pre-existing
staleness: regenerating in a clean origin/main worktree produces no
diff. Generated file only; no generator or config changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Track the MRTR design under issue #6059

The human filed #6059 for exactly the pass-through this document
designs, superseding the closed-not-planned #5759 the doc referenced.
Point the design at the live issue, defer to doc 10's landed rationale
for both current limitations instead of restating it, and delineate the
adjacent Modern-path issues (#6058 streaming, #6064 ping/resultType,
#6065 subscriptions) this design deliberately does not cover.

One genuine divergence is flagged rather than papered over: #6059
sketches wrapping the backend's requestState with vMCP routing context,
while this design relays it verbatim and re-derives routing from the
capability name — because a vMCP-authored wrapper makes vMCP a
state-minting server under MRTR server requirements 4-5, a
key-management surface the verbatim relay avoids. Recorded as an open
decision to resolve at slice 2/3.

Part of #6059. Refs #5743.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Harden the MRTR egress seam per #6074 review

Review (jhrozek) found the seam promising more than it enforced.
Extraction is now fail-closed on three gates the design already
stated but the code did not check: the method allow-list (only
tools/call, resources/read, prompts/get may carry a round), strict
payload decode (a wrong-typed inputRequests or requestState is a
schema violation, not an empty round), and server requirement 6
(an envelope with nothing to fulfill and nothing to echo must not
become a retry-forever round). RequestState becomes *string because
client requirement 2 makes absent-vs-present-and-empty an observable
distinction the retry must preserve.

The carrier and extractor move to pkg/vmcp beside InputRequiredResult
with exported fields, so slice 2's server-side rendering never has to
import the concrete HTTP client and mock-based consumers can construct
the error. Classification and message text are unchanged: the typed
error still unwraps to the client's sentinel and renders byte-
identically — except that a resultType beyond 64 bytes is now
truncated to a prefix plus length, the #6079/#6066 rule, since the
text reaches the downstream client.

Also per review: modernResultTypeInputRequired constant beside the
existing complete constant, the unrecognized-resultType comment cites
the page's MUST rather than the SEP's SHOULD, the sentinel's comment
explains why its message is frozen instead of claiming MRTR is
deferred, the message-pinning test hardcodes the expected string so
test and production cannot drift together, and the verbatim-relay
test asserts raw bytes (assert.Equal) as its name promises.

Part of #6059.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Correct MRTR design claims found false in review

The principal-binding claim in cell 1 was false for two of six
egress strategies: header_injection (static env-mounted secret) and
unauthenticated (no-op) present one vMCP identity for every
downstream user, so the backend's requestState principal binding
cannot protect one downstream user from another. Scope the claim to
the user-derived strategies and state the consequence — under
shared-credential egress vMCP must bind the round itself, because no
other component can — which turns wrap-vs-verbatim into a
configuration-dependent obligation. Record the third arm the framing
missed: a server-side D5 handle over a verbatim backend round, which
closes that gap with no keys at the cost of a durable store on the
pass-through path.

Also from review: the D5 owner check is vacuous under
incomingAuth: anonymous (every session stores the unauthenticated
sentinel), so slice 5 documents that as a single-tenant caveat and
the stronger-than-AEAD claim is scoped to authenticated incoming
auth; the drift list gains the fifth SEP-vs-page divergence
(unrecognized resultType SHOULD vs MUST) and stops calling
requirements 6 and 7 page-only (one is in schema.ts, the other a
core-page MUST); SEP-2577's union freeze does not name InputRequest,
so that citation is scoped to what the SEP actually says; a cell-1
sequence diagram per the arch-docs convention; and the in-flight
list reflects #6050/#6051 having merged.

Doc 10's client-edge section still called the -32021 contract 'the
planned follow-up' — #6061 landed it, and this PR is the
reconciliation pass, so the refresh rides here.

Part of #6059.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Regenerate CRD reference docs after main merge

Same cause as the earlier regen on this branch, not pre-existing
staleness: a clean origin/main (92c70db) worktree regenerates with
no diff, while this branch's additions to pkg/vmcp — whose shared
types the VirtualMCPServer CRD reference renders — make the
generator emit two extra blank lines. The merge of main resolved
docs/operator/crd-api.md to main's copy, dropping the lines the
earlier regen commit had added, so the check failed again on the
merge result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Narrow anonymous-auth handle-theft rationale

The D5 caveat claimed a stolen resume handle concedes no privilege the
anonymous trust model doesn't already grant. That holds for invoking the
tools, but not for the data: resuming loads accumulated step outputs
produced from the original caller's arguments, which a thief running the
same workflow could not necessarily reproduce. Scope the claim to
tool-invocation privilege and name the residual data-read exposure,
which rests on handle secrecy and the existing redaction requirement.

The decision — accept as a single-tenant caveat rather than refuse to
suspend — is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

* Name missing sentinel in InputRequiredError

InputRequiredError.Sentinel is documented as required, but the fields are
exported so callers can build the error without the real client's
envelope decode, and nothing enforces it: a literal omitting Sentinel
rendered "%!s(<nil>)" into text that reaches the downstream client.
Guard both Error() branches with a named fallback so the omission is
diagnosable instead of cryptic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Clean up JSON-RPC error codes and leaked error text on denial paths

2 participants