Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions docs/arch/10-virtual-mcp-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ The client edge mirrors the backend edge. The Modern dispatcher
(SEP-2322) is unimplemented on this edge too. When a backend tool issues a
mid-call server-initiated request during a **Modern** client's `tools/call`,
there is no client session to forward it to, so the call fails with an explicit
`-32603` whose message names the refused request (pinned by
error naming the refused request (pinned by
`TestIntegration_Modern_RealBackend_ElicitingToolFailsCleanly`). This is a
deliberate honest-unsupported error, not a gap left by accident:

Expand All @@ -328,12 +328,16 @@ so a conformant 400 would tear down the entire client session to punish one
call. And for a client that DID declare the capability, the 2026-07-28
vocabulary has no conformant code at all: no "operation not supported", MRTR
is not a server-advertised capability, and SEP-2322 has no decline mechanism.
The planned follow-up therefore serves `-32021` (with
`data.requiredCapabilities`, and a message naming both the capability and the
gateway limitation) at **HTTP 200** for the undeclared case — deviating from
the mandated 400 for exactly the reason above — and keeps `-32603` for the
declared case as a documented spec gap. Until that lands, both cases surface
as `-32603`.
**#6061 (merged) implements exactly that two-path contract** in
`writeModernCallFailure`/`writeModernMissingCapability` (`pkg/vmcp/server`):
`-32021` with `data.requiredCapabilities` and a message naming both the
capability and the gateway limitation, served at **HTTP 200** for the
undeclared case — deviating from the mandated 400 for exactly the reason
above (tracked upstream as go-sdk#1117) — and an explicit `-32603` naming
SEP-2322 for the declared case, as a documented spec gap. The MRTR design
([16-vmcp-mrtr.md](16-vmcp-mrtr.md)) supersedes this contract slice by slice
where the backend is Modern; it is permanent for the Modern-client ↔ Legacy-
backend cell.

**A clean error does not mean nothing happened.** The refusal reaches the
backend mid-call, so a real backend tool may have executed — including side
Expand Down Expand Up @@ -381,11 +385,13 @@ not parked `tools/call`.
The coherent future MRTR shape for a re-aggregating gateway is
**Modern-client ↔ Modern-backend pass-through** — relay a Modern backend's
`inputRequests`/`requestState` to the client and the client's
`inputResponses` back, genuinely stateless at vMCP. It requires the egress
half first (today a Modern backend's `input_required` surfaces as
`errModernInputRequired`, the seam left in `pkg/vmcp/client`), and by the time
Modern backends exist to relay from, SEP-2577's deprecations make elicitation
its only durable consumer; see #5743.
`inputResponses` back, genuinely stateless at vMCP. The full design is
[16-vmcp-mrtr.md](16-vmcp-mrtr.md); its slice 1 (the egress half) has landed —
a Modern backend's `input_required` still classifies as
`errModernInputRequired`, but the typed `vmcp.InputRequiredError` now carries
the decoded round for the relay slices to consume. By the time Modern
backends exist to relay from, SEP-2577's deprecations make elicitation its
only durable consumer; see #5743 and #6059.

Progress and log notifications toward Modern clients are a separate concern
from MRTR: they remain spec-legal as request-scoped notifications on the
Expand Down
544 changes: 544 additions & 0 deletions docs/arch/16-vmcp-mrtr.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/arch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ Welcome to the ToolHive architecture documentation. This directory contains comp
- Dynamic forward proxy with per-request DNS and native HTTPS CONNECT tunnelling
- Squid-vs-Envoy comparison and known limitations (`AllowPort` gap, V4_ONLY DNS, deny-all on empty profile)

16. **[vMCP Multi Round-Trip Requests (MRTR)](16-vmcp-mrtr.md)**
- MRTR (SEP-2322) design for the Modern (2026-07-28) elicitation/sampling path through vMCP
- The four client×backend bridge cells: stateless Modern↔Modern pass-through, in-request Legacy-client bridge
- Per-request capability mirroring and verbatim `requestState` relay
- Composite-workflow suspend/resume as the sole durable-state trigger (RFC-0083 D5 handles, owner binding, TTL)
- Sampling's deprecated-pass-through-only disposition (SEP-2577) and the go-sdk/mcpcompat gap list

### Existing Documentation

For middleware architecture, see: **[docs/middleware.md](../middleware.md)**
Expand Down
4 changes: 4 additions & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 20 additions & 5 deletions pkg/vmcp/client/modern.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const modernClientName = "toolhive-vmcp"
// this shim does not drive — see errModernInputRequired.
const modernResultTypeComplete = "complete"

// modernResultTypeInputRequired is the SEP-2322 envelope resultType announcing
// a Multi Round-Trip Request round. The only non-"complete" resultType whose
// payload survives decode (newInputRequiredError); everything else is sentinel-
// only.
const modernResultTypeInputRequired = "input_required"

// jsonRPCCodeMethodNotFound is the JSON-RPC "method not found" code. Declared
// locally rather than imported (mcpcompat's METHOD_NOT_FOUND is the SDK's wire
// vocabulary; the Modern layer sources its codes independently — see
Expand All @@ -57,10 +63,14 @@ var errWrongEra = errors.New("backend response is not a Modern (2026-07-28) MCP
// of a side-effecting tool). The cache may still be reclassified.
var errLegacyResponseBody = errors.New("backend returned a Legacy-shaped body (no resultType); it may have executed")

// errModernInputRequired is returned when a Modern envelope decodes with a
// resultType other than "complete" (e.g. "input_required"). Multi-round tool
// retrieval is deferred; this shim detects and errors rather than returning a
// blank success.
// errModernInputRequired is the classification sentinel for a Modern envelope
// whose resultType is not "complete" (e.g. "input_required"). The message
// string is frozen for compatibility — it predates the MRTR seam, and both
// probeRevision's errors.Is classification and client-visible error text pin
// it — even though "unsupported" no longer tells the whole story: an
// input_required round's SEP-2322 payload now rides the typed
// vmcp.InputRequiredError wrapping this sentinel, and MRTR consumers branch on
// vmcp.InputRequiredFromError (docs/arch/16-vmcp-mrtr.md, slice 1).
var errModernInputRequired = errors.New("modern response requires additional input (multi-round retrieval unsupported)")

// errModernProtocolError wraps a well-formed JSON-RPC error whose code is one of
Expand Down Expand Up @@ -267,7 +277,12 @@ func interpretModernResult(result json.RawMessage, rpcErr *modernRPCError, metho
// request. Distinct from errWrongEra so the caller does not auto-retry.
return errLegacyResponseBody
default:
return fmt.Errorf("%w: resultType=%q", errModernInputRequired, envelope.ResultType)
// Typed so an "input_required" round's SEP-2322 payload (inputRequests,
// requestState) survives for MRTR consumers (vmcp.InputRequiredFromError)
// — gated on the three methods that may carry a round and on payload
// validity; unwraps to errModernInputRequired with an identical message,
// so classification and behavior are unchanged for everyone else.
return newInputRequiredError(method, envelope.ResultType, result)
}

if out != nil {
Expand Down
70 changes: 70 additions & 0 deletions pkg/vmcp/client/modern_mrtr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package client

import (
"encoding/json"

"github.com/stacklok/toolhive/pkg/vmcp"
)

// mrtrMethods are the only client requests that may carry an input_required
// round: "Servers MUST NOT send InputRequiredResult responses on any other
// client requests" (spec, basic/patterns/mrtr). The set coincides with
// pkg/mcp's nameRequiredMethods today, but the concepts are independent —
// Mcp-Name validation is a header rule, this is the MRTR method allow-list,
// and the final 2026-07-28 cut may grow this one alone (SEP-2322 Final also
// allowed GetTaskPayloadRequest before tasks moved to an extension; see the
// drift list in docs/arch/16-vmcp-mrtr.md) — so they are deliberately not
// shared.
var mrtrMethods = map[string]bool{
"tools/call": true,
"resources/read": true,
"prompts/get": true,
}

// newInputRequiredError builds the typed error (vmcp.InputRequiredError,
// unwrapping to errModernInputRequired) for a non-"complete" Modern result
// envelope. The error always classifies and renders identically to the
// previous fmt.Errorf wrapping; whether it also carries an extractable round
// (vmcp.InputRequiredFromError reporting ok=true) is gated fail-closed:
//
// - resultType must be exactly "input_required" — the spec says an
// unrecognized resultType "MUST be considered invalid", so anything else
// (including the Tasks extension's "task", which vMCP never solicits —
// it advertises no extensions) keeps pure sentinel semantics;
// - method must be one of mrtrMethods — a round on any other request is a
// backend protocol violation, and relaying it would make vMCP the
// non-conformant server;
// - the payload must decode strictly and be drivable: server requirement 6
// mandates at least one of inputRequests or requestState, and an envelope
// violating it (or one with a wrong-typed field) must not surface as a
// round — a consumer following client requirement 1 would retry it having
// gathered nothing, which the backend may answer with the same envelope
// again, a loop with no exit. Fail closed to the sentinel instead.
func newInputRequiredError(method, resultType string, result json.RawMessage) error {
e := &vmcp.InputRequiredError{ResultType: resultType, Sentinel: errModernInputRequired}
if resultType != modernResultTypeInputRequired || !mrtrMethods[method] {
return e
}
var payload struct {
InputRequests map[string]json.RawMessage `json:"inputRequests"`
RequestState *string `json:"requestState"`
}
if err := json.Unmarshal(result, &payload); err != nil {
// Wrong-typed inputRequests/requestState: a schema violation, not a
// drivable round. The sentinel classification survives.
return e
}
if len(payload.InputRequests) == 0 && payload.RequestState == nil {
// Server requirement 6 violation ("at least one of inputRequests or
// requestState"): nothing to fulfill and nothing to echo.
return e
}
e.Result = &vmcp.InputRequiredResult{
InputRequests: payload.InputRequests,
RequestState: payload.RequestState,
}
return e
}
Loading
Loading