Skip to content

Commit 7530d35

Browse files
authored
Replace vMCP Modern kill-switch with a capability gate (#6033)
Delete the TOOLHIVE_VMCP_MODERN_STATELESS env var and gate MCP 2026-07-28 (Modern) dispatch on instance capabilities instead: modernDispatchBlockers() enumerates enabled features the stateless path cannot serve (currently the optimizer, tracked for parity in #6089). A gated instance steers Modern-first clients onto the Legacy handshake via the SDK's discover answer and refuses direct Modern requests with a conformant -32022. sessionmanager.New now rejects an optimizer configured without AdvertiseFromCore, closing a fail-open path where the gate saw no blocker while the optimizer indexed without serving anyone. Operator session-semantics e2e specs are ported onto era-pinned RawMCPClient primitives (mcpcompat cannot pin a revision, #5911), and the raw client parses SSE-framed POST responses. Closes #5959. Follow-ups: #6101, #6102, #6103, #6104.
1 parent 6d6e9f9 commit 7530d35

42 files changed

Lines changed: 1424 additions & 764 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1832,6 +1832,10 @@ spec:
18321832
When enabled, vMCP exposes only find_tool and call_tool operations to clients
18331833
instead of all backend tools directly. This reduces token usage by allowing
18341834
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
1835+
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
1836+
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
1837+
clients are negotiated down to the Legacy revision (see
1838+
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
18351839
properties:
18361840
embeddingHeaders:
18371841
additionalProperties:
@@ -5292,6 +5296,10 @@ spec:
52925296
When enabled, vMCP exposes only find_tool and call_tool operations to clients
52935297
instead of all backend tools directly. This reduces token usage by allowing
52945298
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
5299+
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
5300+
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
5301+
clients are negotiated down to the Legacy revision (see
5302+
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
52955303
properties:
52965304
embeddingHeaders:
52975305
additionalProperties:

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,10 @@ spec:
18351835
When enabled, vMCP exposes only find_tool and call_tool operations to clients
18361836
instead of all backend tools directly. This reduces token usage by allowing
18371837
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
1838+
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
1839+
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
1840+
clients are negotiated down to the Legacy revision (see
1841+
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
18381842
properties:
18391843
embeddingHeaders:
18401844
additionalProperties:
@@ -5295,6 +5299,10 @@ spec:
52955299
When enabled, vMCP exposes only find_tool and call_tool operations to clients
52965300
instead of all backend tools directly. This reduces token usage by allowing
52975301
LLMs to discover relevant tools on demand rather than receiving all tool definitions.
5302+
Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
5303+
find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
5304+
clients are negotiated down to the Legacy revision (see
5305+
pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
52985306
properties:
52995307
embeddingHeaders:
53005308
additionalProperties:

docs/arch/10-virtual-mcp-architecture.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,85 @@ The four Modern list verbs (`tools/list`, `resources/list`, `resources/templates
488488

489489
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.
490490

491+
### Served MCP revisions: the Modern capability gate
492+
493+
vMCP serves two client-facing MCP revisions: **Legacy** (2025-11-25, the SDK
494+
session path) always, and **Modern** (2026-07-28, `classifyingHandler →
495+
dispatchModern`, stateless) **conditionally** — only when every enabled feature
496+
of the instance is servable by the stateless dispatch path. The condition is
497+
`modernDispatchBlockers` (`pkg/vmcp/server/modern_gate.go`), an explicit
498+
enumeration that replaced the temporary `TOOLHIVE_VMCP_MODERN_STATELESS`
499+
env-var kill-switch (#5959): instead of a global "don't serve Modern", the
500+
instance serves Modern exactly when it can serve it correctly.
501+
502+
Features that currently gate Modern off, and why:
503+
504+
| Feature | Why the stateless path cannot serve it |
505+
|---------|----------------------------------------|
506+
| 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 |
507+
508+
"Cannot serve" means a Modern client would silently get different behavior than
509+
the feature promises — not merely that the feature is session-flavored.
510+
Redis-backed session sharing, for example, does **not** gate Modern: Legacy
511+
clients keep their shared, reconstructible sessions while Modern clients are
512+
sessionless by design and store nothing, a coexistence asserted end-to-end by
513+
`test/e2e/thv-operator/virtualmcp/virtualmcp_dual_era_redis_test.go`. Rate
514+
limiting does not gate Modern either: the limiter is a core decorator
515+
(`pkg/vmcp/ratelimit`), so both eras meter the same `CallTool` seam, and the
516+
Modern dispatcher preserves the limiter's coded error on the wire — a real
517+
JSON-RPC error object, `-32029` with `data.retryAfterSeconds`
518+
(`writeModernCodedError`, at HTTP 200 because go-sdk rejects a non-200
519+
response before decoding the body, so on a 429 the error object — and its
520+
`retryAfterSeconds` — would be discarded unread) — where the Legacy SDK seam
521+
can only smuggle the same code and data into an `IsError` tool result's
522+
`structuredContent` (`conversion.ErrorToToolResult`). Note that `-32029` sits
523+
inside the draft spec's reserved band (`-32020`..`-32099`, reserved for
524+
spec-defined codes); reallocating it is tracked in #6101.
525+
526+
"Does not gate Modern" is not "costs the same", though. The limiter wraps only
527+
the `CallTool` seam, so the list verbs and `server/discover` are unmetered on
528+
both eras — but Legacy aggregates once per session registration, while Modern
529+
re-runs the full backend fan-out on every request with no cache, by design
530+
(`core_vmcp.go`'s `aggregatedView`). A Modern client can therefore loop
531+
unmetered, uncached fan-outs — reachable unauthenticated when incoming auth is
532+
anonymous. Rate-limiting the list/discover verbs, or a short-TTL per-identity
533+
capability cache, is deferred until profiling shows the per-request fan-out
534+
cost matters (#5761 — the same deferral recorded in `dispatchModernDiscover`'s
535+
doc comment).
536+
537+
Wire behavior when the gate is closed:
538+
539+
- **`server/discover` falls through to the SDK** instead of dispatching. go-sdk's
540+
`filterSupportedVersions` keeps every version the transport's
541+
`SupportsProtocolVersion` accepts, and the stateful transport excludes only
542+
>= 2026-07-28 (those require `Stateless`), so the probe answer is the
543+
transport-filtered list — everything except Modern: `[2025-11-25,
544+
2025-06-18, 2025-03-26, 2024-11-05]`. This matters because go-sdk v1.7+
545+
clients are Modern-first: `Connect` probes `server/discover` **before**
546+
`initialize` and upgrades to whatever the server advertises. The
547+
fall-through answer is what lands them on the Legacy handshake — where
548+
sessions, and every gated feature, work. That answer over-advertises,
549+
though: the `-32022` refusal below lists only 2025-11-25, and the refusal is
550+
the accurate one — `mcpcompat`'s `handleInitialize` always responds with
551+
`LATEST_PROTOCOL_VERSION` regardless of what the client requests, so the
552+
three older revisions in the discover answer are not actually servable. The
553+
mismatch is the SDK's discover answer to narrow, not the `-32022` list to
554+
widen.
555+
- **Every other well-formed Modern request** is refused with a conformant
556+
400 + `-32022 UnsupportedProtocolVersionError` whose data lists the Legacy
557+
version, the shape a client negotiates down from. It is answered in
558+
`classifyingHandler` rather than falling through, because go-sdk's stateful
559+
rejection for Modern traffic is a plain-text 400 carrying no version list.
560+
- **Legacy traffic is untouched** either way; the gate only ever affects
561+
requests that classified Modern.
562+
563+
The gate is derived from construction-time configuration, logged once at
564+
startup ("MCP 2026-07-28 (Modern) dispatch disabled…"), and pinned by
565+
`TestModernDispatchBlockers`, `TestClassifyingHandler_ModernCapabilityGate`,
566+
and the full-handler pair in `modern_gate_integration_test.go`. Achieving
567+
Modern parity for a feature means deleting its entry and updating those tests —
568+
nothing else needs to change.
569+
491570
### Subscription limitation (ack-level)
492571

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

docs/arch/vmcp-local.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ thv vmcp serve --config vmcp.yaml
103103
| 2 | `--optimizer-embedding` | FTS5 + TEI semantic | Managed TEI container | `find_tool`, `call_tool` only |
104104
| 3 | `optimizer.embeddingService` in config YAML | FTS5 + external embedding service | User-managed | `find_tool`, `call_tool` only |
105105

106+
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).
107+
106108
Tier 2 (`--optimizer-embedding`) implies `--optimizer`. The TEI container is started automatically and stopped on server shutdown.
107109

108110
**Implementation**: `pkg/vmcp/optimizer/optimizer.go`, `pkg/vmcp/cli/embedding_manager.go`

docs/operator/crd-api.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/ratelimit/errors.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import (
1212

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

1822
// MessageRateLimited is the error message for rate-limited requests.

pkg/vmcp/cli/serve.go

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"net"
1616
"os"
1717
"path/filepath"
18-
"strconv"
1918
"time"
2019

2120
"go.opentelemetry.io/otel/trace"
@@ -52,12 +51,6 @@ import (
5251
vmcpstatus "github.com/stacklok/toolhive/pkg/vmcp/status"
5352
)
5453

55-
// modernDispatchEnvVar is the kill-switch env var for direct-to-core dispatch
56-
// of well-formed MCP 2026-07-28 ("Modern") stateless requests (default off;
57-
// see server.Config.ModernDispatchEnabled). Env-only ahead of any CLI-flag or
58-
// CRD wiring.
59-
const modernDispatchEnvVar = "TOOLHIVE_VMCP_MODERN_STATELESS"
60-
6154
// ServeConfig holds all parameters needed to start the vMCP server.
6255
// Populated by the caller from Cobra flag values or equivalent.
6356
// At least one of ConfigPath or GroupRef must be non-empty; ConfigPath takes
@@ -419,21 +412,6 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
419412
}()
420413
}
421414

422-
// Read the Modern-stateless-dispatch kill-switch once, here at the
423-
// composition root. Unset is the deliberate "off" default. A non-empty
424-
// value that fails to parse as a bool is an operator typo (a misspelled
425-
// "true"), not a request to enable the feature — warn and stay disabled
426-
// rather than silently treating it as false.
427-
modernDispatchEnabled := false
428-
if raw := os.Getenv(modernDispatchEnvVar); raw != "" {
429-
var err error
430-
if modernDispatchEnabled, err = strconv.ParseBool(raw); err != nil {
431-
slog.Warn(fmt.Sprintf("%s has an unrecognized value %q; Modern stateless dispatch stays disabled",
432-
modernDispatchEnvVar, raw))
433-
modernDispatchEnabled = false
434-
}
435-
}
436-
437415
// Resolve transport defaults once here at the composition root: the
438416
// vMCP config edge is the single place flags/CRD/YAML become a fully-resolved
439417
// Config, so server.New, Serve, and the derive* helpers downstream are pure
@@ -446,7 +424,6 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
446424
Host: cfg.Host,
447425
Port: cfg.Port,
448426
SessionTTL: cfg.SessionTTL,
449-
ModernDispatchEnabled: modernDispatchEnabled,
450427
AuthMiddleware: authMiddleware,
451428
AuthzMiddleware: authzMiddleware,
452429
AuthInfoHandler: authInfoHandler,

pkg/vmcp/client/modern_integration_test.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,6 @@ func newModernVMCPServer(t *testing.T, backendURL string) *httptest.Server {
9595
SessionTTL: 5 * time.Minute,
9696
SessionFactory: vmcpsession.NewSessionFactory(authReg),
9797
Aggregator: agg,
98-
// main re-added this kill-switch (default off, #5959); the harness
99-
// must opt in so a well-formed Modern request reaches dispatchModern
100-
// rather than falling through to the Legacy SDK path.
101-
ModernDispatchEnabled: true,
10298
},
10399
router.NewSessionRouter(&vmcp.RoutingTable{}),
104100
backendClient,

pkg/vmcp/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ type Config struct {
166166
// When enabled, vMCP exposes only find_tool and call_tool operations to clients
167167
// instead of all backend tools directly. This reduces token usage by allowing
168168
// LLMs to discover relevant tools on demand rather than receiving all tool definitions.
169+
// Enabling the optimizer currently pins this instance to MCP 2025-11-25: the
170+
// find_tool/call_tool meta-tools are session-scoped, so Modern-capable (2026-07-28)
171+
// clients are negotiated down to the Legacy revision (see
172+
// pkg/vmcp/server/modern_gate.go; Modern parity is tracked in stacklok/toolhive#6089).
169173
// +optional
170174
Optimizer *OptimizerConfig `json:"optimizer,omitempty" yaml:"optimizer,omitempty"`
171175

pkg/vmcp/conversion/errors.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@ import (
1717
// Go errors: the SDK maps them to generic internal errors. For domain errors
1818
// that opt into CodedError, preserve the code/data in StructuredContent instead.
1919
func ErrorToToolResult(err error) *sdkmcp.CallToolResult {
20+
// Denial first (matches writeModernDispatchError): an error that is both a
21+
// CodedError and wraps ErrAuthorizationFailed must render as the denial,
22+
// never as retry-shaped coded data (the sets are disjoint today; this
23+
// ordering is the invariant).
24+
if errors.Is(err, vmcp.ErrAuthorizationFailed) {
25+
return sdkmcp.NewToolResultError(vmcp.DenyMessageToolCall)
26+
}
2027
var coded thvmcp.CodedError
2128
if errors.As(err, &coded) {
2229
return CodedErrorResult(err, coded)
2330
}
24-
if errors.Is(err, vmcp.ErrAuthorizationFailed) {
25-
return sdkmcp.NewToolResultError(vmcp.DenyMessageToolCall)
26-
}
2731
return sdkmcp.NewToolResultError(err.Error())
2832
}
2933

@@ -36,7 +40,7 @@ func CodedErrorResult(err error, coded thvmcp.CodedError) *sdkmcp.CallToolResult
3640
"code": coded.Code(),
3741
"message": err.Error(),
3842
}
39-
if data := coded.Data(); data != nil {
43+
if data := coded.Data(); len(data) > 0 {
4044
structured["data"] = data
4145
}
4246
result.StructuredContent = structured

0 commit comments

Comments
 (0)