Skip to content

Classify Modern mid-call capability refusals per spec - #6061

Merged
ChrisJBurns merged 5 commits into
mainfrom
feat-modern-capability-errors
Jul 28, 2026
Merged

Classify Modern mid-call capability refusals per spec#6061
ChrisJBurns merged 5 commits into
mainfrom
feat-modern-capability-errors

Conversation

@JAORMX

@JAORMX JAORMX commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Why. When a backend tool demands mid-call elicitation or sampling during a Modern (2026-07-28) client's tools/call, there is no downstream session to forward it to (the revision removed server-initiated requests), and today the failure surfaces as an opaque -32603 echoing the backend's raw error chain — including the backend workload ID, contradicting writeModernListError's leak policy in the same file. The draft schema defines the honest answer for the common case: MissingRequiredClientCapabilityError (-32021, data.requiredCapabilities), which a client can act on where -32603 is opaque.

The key insight that shapes the design (and why this is not just an error-code swap): the refusal evidence cannot ride the error chain. The typed refusal — mcpcompat's server.ErrNoActiveSession, raised inside vMCP's own sdkElicitationAdapter/sdkSamplingAdapter — is the answer to the backend's elicitation request; the backend then fails its own tool, and what the dispatcher receives back over the wire is a flattened string. So classification uses an in-process capabilityRefusalRecorder written at the exact point of refusal and read after a failed call.

The contract (writeModernCallFailure), two paths:

  • Capability NOT declared in the request's _meta clientCapabilities-32021 with data.requiredCapabilities typed as a ClientCapabilities object ({"elicitation":{}}, matching the spec's example), and a message naming both the capability and the gateway limitation — so a caller that reacts to -32021 by declaring the capability on a retry learns immediately that doing so won't help here.
  • Capability DECLARED → deliberately NOT -32021 (the client did declare it; blaming it would be wrong). The spec's answer for this case is MRTR input_required (SEP-2322), which vMCP deliberately does not implement, and the eight-code 2026-07-28 vocabulary has no "operation not supported" alternative — a documented spec gap. It stays -32603 with a vMCP-owned message naming multi-round retrieval as the cause.

Both messages are crafted by vMCP, closing the workload-ID leak on this path (asserted by NotContains in the new test).

The two things a reviewer must weigh

1. The -32021 is served at HTTP 200 — a deliberate, documented deviation from the spec-mandated 400. SEP-2575:391-396 MUSTs 400 (execution-time language; go-sdk's own doc comment on the error type tells handlers to return it mid-execution, so the timing is sanctioned). But go-sdk's streamable client (v1.7.0-pre.3) treats any non-transient 4xx as a connection failure: isTransientHTTPStatus covers only 500/502/503/504/429, so a 400 falls through checkResponse to fail(), which closes the session permanently — one refused elicitation would kill every subsequent request on the client. The JSON-RPC body stays fully conformant (what a non-go-sdk client parses); only the transport status deviates. Tracked upstream as go-sdk#1117; the comment on writeModernMissingCapability says to revisit the 200 when it's fixed. mcpparser.WriteClassificationError is deliberately NOT reused — it hard-codes 400, correctly for its other callers.

2. The context-carried recorder, versus vmcp-anti-patterns #1. The rule targets domain data flowing invisibly between middleware. This is a transport-boundary observation channel written by a lower layer, mirroring the audit.BackendInfoFromContext pattern these same dispatch verbs already use. The crux of why nothing else can carry it: MultiSession cannot — its absence is the very thing being recorded — and the requesters are bound once at startup (BindForwarders), so a per-call parameter cannot reach them. Disagree on the merits here if you disagree; the reasoning is in modern_capability_refusal.go's type comment.

One subtlety worth review attention: a client decline must NOT record a refusal — a user's "no" is not a capability gap — so the adapters record only on errors.Is(err, server.ErrNoActiveSession), and the unit tests pin that distinction explicitly.

Provenance and relationship to #6051

This work was split out of #6051 after review: that PR's security approval was based on it being test-and-docs only, so the production change moved here for its own review (which is why the work predates the PR). References #5959, #6033, #5743. It completes the honest-negotiation contract whose analysis lives in docs/arch/10-virtual-mcp-architecture.md (added by #6051), and it delivers the "vMCP-owned message with our own surface to assert on" that the #6051 review asked for.

Landing-order note (either order works):

Type of change

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

Test plan

  • Unit tests (task test scope: full pkg/vmcp/server package green, 94.7s; new unit tests for the declared-capability helper, the recorder, and both adapters' record-only-on-ErrNoActiveSession behavior including the decline case)
  • Linting (task lint) — 0 issues; go vet ./... run separately, clean
  • Manual testing (describe below)

Manual verification:

  1. Mutation AmodernClientDeclaredCapability mutated to always-true: exactly the 2 undeclared subtests of TestIntegration_Modern_RealBackend_MidCallCapabilityContract fail (they get -32603 instead of -32021); declared subtests pass. Restored: 4/4 pass.
  2. Mutation B — the refusal block in writeModernCallFailure skipped: all 4 subtests fail (no crafted message, wrong code, leak assertions trip). Restored: 4/4 pass.
  3. Legacy behavior byte-identical: the recorder is installed only by the Modern dispatch verbs; on the Legacy/SDK path no recorder exists and the adapters' recording is a no-op. Full package (including all Legacy forwarding tests) green.

Does this introduce a user-facing change?

Yes: Modern (2026-07-28) clients calling a tool that requires mid-call elicitation/sampling now receive an actionable -32021 MissingRequiredClientCapability error (with data.requiredCapabilities) when they did not declare the capability, and a clear -32603 naming multi-round retrieval (SEP-2322) as the unimplemented mechanism when they did — instead of an opaque internal error echoing backend internals.

Special notes for reviewers

🤖 Generated with Claude Code

https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam

When a backend tool demands mid-call elicitation or sampling during a
Modern (2026-07-28) client's call, there is no downstream session to
forward it to, and the failure surfaced as an opaque -32603 carrying
the backend's laundered error string - including the backend workload
ID. Per the draft schema, the honest answer when the client did not
declare the capability is MissingRequiredClientCapabilityError: code
-32021 with data.requiredCapabilities naming what the server needed.
When the client DID declare it, -32021 would wrongly blame the client
(and re-declaring cannot help), and the 2026-07-28 vocabulary has no
"operation not supported" code - so that case stays -32603 with a
vMCP-owned message naming multi-round retrieval (SEP-2322) as the
gateway limitation. Both messages are crafted, closing the workload-ID
leak on this path.

The -32021 is served at HTTP 200, a deliberate, documented deviation
from the spec-mandated 400: go-sdk's streamable client treats a
non-transient 4xx as a connection failure and permanently fails the
session (its transient set is only 500/502/503/504/429), so one refused
elicitation would kill every subsequent request. The JSON-RPC body
stays fully conformant; tracked upstream as go-sdk#1117.

The refusal evidence cannot ride the error chain - the typed
ErrNoActiveSession is flattened into a plain string at the wire
boundary when the forwarder answers the backend and the backend fails
its tool - so an in-process recorder (mirroring the audit.BackendInfo
transport-boundary pattern) is installed by the three Modern call verbs
and written by the SDK requester adapters at the exact point of
refusal. A client DECLINE never records: misclassifying a user's "no"
as a capability gap would be wrong, so only ErrNoActiveSession does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Jul 27, 2026
JAORMX added a commit that referenced this pull request Jul 27, 2026
The leak fix (vMCP-owned messages in the capability-error contract,
#6061) is imminent, so an assertion whose own comment says to flip it
shortly is churn that makes the fix noisier rather than clearer. The
message is now asserted on nothing at all - deliberately: its only
identifying substrings are either go-sdk's error wrapping (a
dependency's strings) or the leak itself; both message properties are
asserted in #6061 where vMCP owns the text. The HTTP 200 assertion
returns in its place, valid across the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.21%. Comparing base (a099fa0) to head (71f61a6).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6061      +/-   ##
==========================================
+ Coverage   72.20%   72.21%   +0.01%     
==========================================
  Files         721      722       +1     
  Lines       75062    75138      +76     
==========================================
+ Hits        54198    54262      +64     
- Misses      17003    17013      +10     
- Partials     3861     3863       +2     

☔ 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.

@ChrisJBurns

Copy link
Copy Markdown
Collaborator

/retest

1 similar comment
@ChrisJBurns

Copy link
Copy Markdown
Collaborator

/retest

@amirejaz amirejaz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — this is high-quality, unusually well-documented work with solid test coverage (mutation testing, the leak NotContains assertion, and the decline-vs-refusal distinction all pinned explicitly). Nothing here is a blocker. A couple of non-blocking observations for the record:

1. The load-bearing invariant is context propagation. The whole mechanism hinges on the ctx carrying the recorder flowing unbroken from dispatchModern*core.CallTool → backend client → sdkElicitationAdapter.RequestElicitation. That's not provable from the diff alone, but TestIntegration_Modern_RealBackend_MidCallCapabilityContract + Mutation B is exactly the right guardian for it. Worth making sure that integration test can't be silently t.Skip-ed in CI, since it's the thing standing between this and a regression.

2. Refusal→failure is a heuristic, not proven causation (minor). writeModernCallFailure attributes any non-authz call error to the recorded refusal. If a backend requested elicitation (refused + recorded), then recovered and later failed for an unrelated reason, the client would get capability-flavored messaging for an unrelated failure. Realistically the refusal is what fails the tool, so this is an acceptable trade-off — I'd just soften the doc comment to acknowledge it's the common-case attribution rather than certain causation.

3. (nit) Message wording duplication. The declared-capability -32603 message built inline in writeModernCallFailure restates most of writeModernMissingCapability's SEP-2322 wording; a shared message helper would keep them from drifting over time.

The HTTP 200-on-a-MUST-400 deviation is well justified (go-sdk would treat a 400 as permanent connection death) and already tracked as go-sdk#1117 with a documented revisit trigger — comfortable with it as-is.

@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 27, 2026
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 28, 2026
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 28, 2026
@ChrisJBurns
ChrisJBurns merged commit 0255856 into main Jul 28, 2026
80 of 81 checks passed
@ChrisJBurns
ChrisJBurns deleted the feat-modern-capability-errors branch July 28, 2026 01:25
JAORMX added a commit that referenced this pull request Jul 28, 2026
The leak fix (vMCP-owned messages in the capability-error contract,
#6061) is imminent, so an assertion whose own comment says to flip it
shortly is churn that makes the fix noisier rather than clearer. The
message is now asserted on nothing at all - deliberately: its only
identifying substrings are either go-sdk's error wrapping (a
dependency's strings) or the leak itself; both message properties are
asserted in #6061 where vMCP owns the text. The HTTP 200 assertion
returns in its place, valid across the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam
JAORMX added a commit that referenced this pull request Jul 28, 2026
Brings in #6051 (doc 10 client-edge limitation section, which this
design defers to), #6061's merged contract, and #6079's error-text
hygiene precedent, all referenced by review feedback on #6074.

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
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
JAORMX added a commit that referenced this pull request Jul 28, 2026
With Modern dispatch now unconditional, a rate-limited tools/call
reached Modern clients as an opaque -32603 instead of RFC THV-0057's
-32029 with data.retryAfterSeconds: writeModernDispatchError laundered
every non-authz error into the generic internal code, dropping the
code and data that the Legacy seam preserves in structuredContent
(conversion.ErrorToToolResult). Clients lost the machine-readable
retry metadata exactly on the path that is now the default.

Add a CodedError branch mirroring the Legacy seam's posture and #6061's
capability-refusal classification: the dispatcher owns the envelope, so
it emits the real JSON-RPC error object. HTTP status stays 200 because
-32029's natural 429 is in go-sdk's transient retry set and would be
silently retried instead of surfaced.

The rate-limiting operator spec asserted the Legacy structuredContent
rendering through a client that now negotiates Modern; it asserts the
Modern surface instead, pinning the full envelope with an era-pinned
raw request per the #6051 convention.

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>
JAORMX added a commit that referenced this pull request Jul 28, 2026
With Modern dispatch now unconditional, a rate-limited tools/call
reached Modern clients as an opaque -32603 instead of RFC THV-0057's
-32029 with data.retryAfterSeconds: writeModernDispatchError laundered
every non-authz error into the generic internal code, dropping the
code and data that the Legacy seam preserves in structuredContent
(conversion.ErrorToToolResult). Clients lost the machine-readable
retry metadata exactly on the path that is now the default.

Add a CodedError branch mirroring the Legacy seam's posture and #6061's
capability-refusal classification: the dispatcher owns the envelope, so
it emits the real JSON-RPC error object. HTTP status stays 200 because
-32029's natural 429 is in go-sdk's transient retry set and would be
silently retried instead of surfaced.

The rate-limiting operator spec asserted the Legacy structuredContent
rendering through a client that now negotiates Modern; it asserts the
Modern surface instead, pinning the full envelope with an era-pinned
raw request per the #6051 convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants