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
Original file line number Diff line number Diff line change
Expand Up @@ -1819,6 +1819,10 @@ spec:
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
clients are negotiated down to the Legacy revision (see
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
properties:
embeddingHeaders:
additionalProperties:
Expand Down Expand Up @@ -5266,6 +5270,10 @@ spec:
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
clients are negotiated down to the Legacy revision (see
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
properties:
embeddingHeaders:
additionalProperties:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1822,6 +1822,10 @@ spec:
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
clients are negotiated down to the Legacy revision (see
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
properties:
embeddingHeaders:
additionalProperties:
Expand Down Expand Up @@ -5269,6 +5273,10 @@ spec:
When enabled, vMCP exposes only find_tool and call_tool operations to clients
instead of all backend tools directly. This reduces token usage by allowing
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
clients are negotiated down to the Legacy revision (see
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
properties:
embeddingHeaders:
additionalProperties:
Expand Down
79 changes: 79 additions & 0 deletions docs/arch/10-virtual-mcp-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,85 @@ The four Modern list verbs (`tools/list`, `resources/list`, `resources/templates

The completion handler is a single global handler installed via `WithCompletionHandler`, so it recovers the session from the SDK request context rather than a per-session closure. Setting it makes the shim auto-advertise the `completions` capability at initialize.

### Served MCP revisions: the Modern capability gate

vMCP serves two client-facing MCP revisions: **Legacy** (2025-11-25, the SDK
session path) always, and **Modern** (2026-07-28, `classifyingHandler →
dispatchModern`, stateless) **conditionally** — only when every enabled feature
of the instance is servable by the stateless dispatch path. The condition is
`modernDispatchBlockers` (`pkg/vmcp/server/modern_gate.go`), an explicit
enumeration that replaced the temporary `TOOLHIVE_VMCP_MODERN_STATELESS`
env-var kill-switch (#5959): instead of a global "don't serve Modern", the
instance serves Modern exactly when it can serve it correctly.

Features that currently gate Modern off, and why:

| Feature | Why the stateless path cannot serve it |
|---------|----------------------------------------|
| Optimizer (`find_tool`/`call_tool`) | The meta-tools are Serve-layer and **session-scoped** (`serve_optimizer.go`): each session builds an FTS5 index over its advertised set and swaps the two meta-tools in. The index is deliberately not in the stateless core, and `dispatchModern` serves `tools/*` straight from `core.ListTools`/`core.CallTool` — a Modern client would silently receive the raw aggregated tool set and `tools/call find_tool` would fail. Parity needs an identity- or instance-scoped index |
Comment thread
JAORMX marked this conversation as resolved.

"Cannot serve" means a Modern client would silently get different behavior than
the feature promises — not merely that the feature is session-flavored.
Redis-backed session sharing, for example, does **not** gate Modern: Legacy
clients keep their shared, reconstructible sessions while Modern clients are
sessionless by design and store nothing, a coexistence asserted end-to-end by
`test/e2e/thv-operator/virtualmcp/virtualmcp_dual_era_redis_test.go`. Rate
limiting does not gate Modern either: the limiter is a core decorator
(`pkg/vmcp/ratelimit`), so both eras meter the same `CallTool` seam, and the
Comment thread
JAORMX marked this conversation as resolved.
Modern dispatcher preserves the limiter's coded error on the wire — a real
JSON-RPC error object, `-32029` with `data.retryAfterSeconds`
(`writeModernCodedError`, at HTTP 200 because go-sdk rejects a non-200
response before decoding the body, so on a 429 the error object — and its
`retryAfterSeconds` — would be discarded unread) — where the Legacy SDK seam
can only smuggle the same code and data into an `IsError` tool result's
`structuredContent` (`conversion.ErrorToToolResult`). Note that `-32029` sits
inside the draft spec's reserved band (`-32020`..`-32099`, reserved for
spec-defined codes); reallocating it is tracked in #6101.

"Does not gate Modern" is not "costs the same", though. The limiter wraps only
the `CallTool` seam, so the list verbs and `server/discover` are unmetered on
both eras — but Legacy aggregates once per session registration, while Modern
re-runs the full backend fan-out on every request with no cache, by design
(`core_vmcp.go`'s `aggregatedView`). A Modern client can therefore loop
unmetered, uncached fan-outs — reachable unauthenticated when incoming auth is
anonymous. Rate-limiting the list/discover verbs, or a short-TTL per-identity
capability cache, is deferred until profiling shows the per-request fan-out
cost matters (#5761 — the same deferral recorded in `dispatchModernDiscover`'s
doc comment).

Wire behavior when the gate is closed:

- **`server/discover` falls through to the SDK** instead of dispatching. go-sdk's
`filterSupportedVersions` keeps every version the transport's
`SupportsProtocolVersion` accepts, and the stateful transport excludes only
>= 2026-07-28 (those require `Stateless`), so the probe answer is the
transport-filtered list — everything except Modern: `[2025-11-25,
2025-06-18, 2025-03-26, 2024-11-05]`. This matters because go-sdk v1.7+
clients are Modern-first: `Connect` probes `server/discover` **before**
`initialize` and upgrades to whatever the server advertises. The
fall-through answer is what lands them on the Legacy handshake — where
sessions, and every gated feature, work. That answer over-advertises,
though: the `-32022` refusal below lists only 2025-11-25, and the refusal is
the accurate one — `mcpcompat`'s `handleInitialize` always responds with
`LATEST_PROTOCOL_VERSION` regardless of what the client requests, so the
three older revisions in the discover answer are not actually servable. The
mismatch is the SDK's discover answer to narrow, not the `-32022` list to
widen.
- **Every other well-formed Modern request** is refused with a conformant
400 + `-32022 UnsupportedProtocolVersionError` whose data lists the Legacy
version, the shape a client negotiates down from. It is answered in
`classifyingHandler` rather than falling through, because go-sdk's stateful
rejection for Modern traffic is a plain-text 400 carrying no version list.
- **Legacy traffic is untouched** either way; the gate only ever affects
requests that classified Modern.

The gate is derived from construction-time configuration, logged once at
startup ("MCP 2026-07-28 (Modern) dispatch disabled…"), and pinned by
`TestModernDispatchBlockers`, `TestClassifyingHandler_ModernCapabilityGate`,
and the full-handler pair in `modern_gate_integration_test.go`. Achieving
Modern parity for a feature means deleting its entry and updating those tests —
nothing else needs to change.

### Subscription limitation (ack-level)

vMCP advertises `resources.subscribe: true` and answers `resources/subscribe` / `resources/unsubscribe` at **ack level**: the request is accepted (enforcing session binding and validating the URI is an advertised, admitted resource), and go-sdk records the subscription. vMCP does **not** currently propagate backend `notifications/resources/updated` to the subscribed client — doing so requires persistent per-session backend connections, which is out of scope. Clients that subscribe will receive a success ack but no update stream yet.
Expand Down
2 changes: 2 additions & 0 deletions docs/arch/vmcp-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ thv vmcp serve --config vmcp.yaml
| 2 | `--optimizer-embedding` | FTS5 + TEI semantic | Managed TEI container | `find_tool`, `call_tool` only |
| 3 | `optimizer.embeddingService` in config YAML | FTS5 + external embedding service | User-managed | `find_tool`, `call_tool` only |

Any tier >= 1 keeps clients on MCP 2025-11-25 (Legacy): the `find_tool`/`call_tool` meta-tools are session-scoped, so Modern-capable (2026-07-28) clients are negotiated down to Legacy by the capability gate (`pkg/vmcp/server/modern_gate.go`; Modern parity is tracked in #6089).

Tier 2 (`--optimizer-embedding`) implies `--optimizer`. The TEI container is started automatically and stopped on server shutdown.

**Implementation**: `pkg/vmcp/optimizer/optimizer.go`, `pkg/vmcp/cli/embedding_manager.go`
Expand Down
2 changes: 1 addition & 1 deletion docs/operator/crd-api.md

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

6 changes: 5 additions & 1 deletion pkg/ratelimit/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import (

const (
// CodeRateLimited is the JSON-RPC error code for rate-limited requests.
// Per RFC THV-0057: implementation-defined code in the -32000 to -32099 range.
// It was chosen (RFC THV-0057) when -32000..-32099 was uniformly
// implementation-defined, but the draft MCP spec now reserves
// -32020..-32099 exclusively for spec-defined codes, which this is not.
// The value is retained for wire compatibility; reallocation outside
// -32768..-32000 is tracked in #6101.
CodeRateLimited int64 = -32029

// MessageRateLimited is the error message for rate-limited requests.
Expand Down
23 changes: 0 additions & 23 deletions pkg/vmcp/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"net"
"os"
"path/filepath"
"strconv"
"time"

"go.opentelemetry.io/otel/trace"
Expand Down Expand Up @@ -52,12 +51,6 @@ import (
vmcpstatus "github.com/stacklok/toolhive/pkg/vmcp/status"
)

// modernDispatchEnvVar is the kill-switch env var for direct-to-core dispatch
// of well-formed MCP 2026-07-28 ("Modern") stateless requests (default off;
// see server.Config.ModernDispatchEnabled). Env-only ahead of any CLI-flag or
// CRD wiring.
const modernDispatchEnvVar = "TOOLHIVE_VMCP_MODERN_STATELESS"

// ServeConfig holds all parameters needed to start the vMCP server.
// Populated by the caller from Cobra flag values or equivalent.
// At least one of ConfigPath or GroupRef must be non-empty; ConfigPath takes
Expand Down Expand Up @@ -419,21 +412,6 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
}()
}

// Read the Modern-stateless-dispatch kill-switch once, here at the
// composition root. Unset is the deliberate "off" default. A non-empty
// value that fails to parse as a bool is an operator typo (a misspelled
// "true"), not a request to enable the feature — warn and stay disabled
// rather than silently treating it as false.
modernDispatchEnabled := false
if raw := os.Getenv(modernDispatchEnvVar); raw != "" {
var err error
if modernDispatchEnabled, err = strconv.ParseBool(raw); err != nil {
slog.Warn(fmt.Sprintf("%s has an unrecognized value %q; Modern stateless dispatch stays disabled",
modernDispatchEnvVar, raw))
modernDispatchEnabled = false
}
}

// Resolve transport defaults once here at the composition root: the
// vMCP config edge is the single place flags/CRD/YAML become a fully-resolved
// Config, so server.New, Serve, and the derive* helpers downstream are pure
Expand All @@ -446,7 +424,6 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
Host: cfg.Host,
Port: cfg.Port,
SessionTTL: cfg.SessionTTL,
ModernDispatchEnabled: modernDispatchEnabled,
AuthMiddleware: authMiddleware,
AuthzMiddleware: authzMiddleware,
AuthInfoHandler: authInfoHandler,
Expand Down
4 changes: 0 additions & 4 deletions pkg/vmcp/client/modern_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,6 @@ func newModernVMCPServer(t *testing.T, backendURL string) *httptest.Server {
SessionTTL: 5 * time.Minute,
SessionFactory: vmcpsession.NewSessionFactory(authReg),
Aggregator: agg,
// main re-added this kill-switch (default off, #5959); the harness
// must opt in so a well-formed Modern request reaches dispatchModern
// rather than falling through to the Legacy SDK path.
ModernDispatchEnabled: true,
},
router.NewSessionRouter(&vmcp.RoutingTable{}),
backendClient,
Expand Down
4 changes: 4 additions & 0 deletions pkg/vmcp/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ type Config struct {
// When enabled, vMCP exposes only find_tool and call_tool operations to clients
// instead of all backend tools directly. This reduces token usage by allowing
// LLMs to discover relevant tools on demand rather than receiving all tool definitions.
// Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
// find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
// clients are negotiated down to the Legacy revision (see
// pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
// +optional
Optimizer *OptimizerConfig `json:"optimizer,omitempty" yaml:"optimizer,omitempty"`

Expand Down
12 changes: 8 additions & 4 deletions pkg/vmcp/conversion/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ import (
// Go errors: the SDK maps them to generic internal errors. For domain errors
// that opt into CodedError, preserve the code/data in StructuredContent instead.
func ErrorToToolResult(err error) *sdkmcp.CallToolResult {
// Denial first (matches writeModernDispatchError): an error that is both a
// CodedError and wraps ErrAuthorizationFailed must render as the denial,
// never as retry-shaped coded data (the sets are disjoint today; this
// ordering is the invariant).
if errors.Is(err, vmcp.ErrAuthorizationFailed) {
return sdkmcp.NewToolResultError(vmcp.DenyMessageToolCall)
}
var coded thvmcp.CodedError
if errors.As(err, &coded) {
return CodedErrorResult(err, coded)
}
if errors.Is(err, vmcp.ErrAuthorizationFailed) {
return sdkmcp.NewToolResultError(vmcp.DenyMessageToolCall)
}
return sdkmcp.NewToolResultError(err.Error())
}

Expand All @@ -36,7 +40,7 @@ func CodedErrorResult(err error, coded thvmcp.CodedError) *sdkmcp.CallToolResult
"code": coded.Code(),
"message": err.Error(),
}
if data := coded.Data(); data != nil {
if data := coded.Data(); len(data) > 0 {
structured["data"] = data
}
result.StructuredContent = structured
Expand Down
9 changes: 0 additions & 9 deletions pkg/vmcp/server/authz_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,15 +178,6 @@ func buildCedarAuthzServer(
Authz: authzCfg,
AuditConfig: auditCfg,
CodeModeConfig: codeModeCfg,
// Required for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to
// exercise what it claims: dispatchModern's re-homed call gate. Without
// it the kill switch refuses the Modern request in classifyingHandler and
// dispatchModern never runs. (Before the classifier learned to refuse an
// unserved revision, the request instead fell through to the SDK path, so
// that test passed on a denial from a different gate entirely.) Safe for
// the Legacy-shaped tests sharing this helper: classifyingHandler passes
// Legacy traffic through regardless of this flag.
ModernDispatchEnabled: true,
},
router.NewSessionRouter(&vmcp.RoutingTable{}),
backendClient,
Expand Down
Loading
Loading