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
32 changes: 22 additions & 10 deletions docs/arch/10-virtual-mcp-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,28 @@ Reclassification never re-runs a request that may already have executed. An
request) corrects the cache for *future* calls but returns the error, because the
backend may have performed the side effect.

### Reserved `_meta` must not cross a Legacy hop

Before any Legacy backend call, vMCP strips the reserved
`io.modelcontextprotocol/*` keys from a caller-supplied `_meta`
(`mcp.StripReservedModernMeta`, applied in `pkg/vmcp/client` and
`pkg/vmcp/session/internal/backend`). This is not cosmetic: go-sdk v1.7 rejects
**any** `_meta.protocolVersion` on a stateful streamable-HTTP server outright
(HTTP 400), regardless of its value. vMCP — not the downstream caller — is the
backend's MCP peer on that hop, so those keys are vMCP's to own. Non-reserved
caller keys (progress tokens, W3C trace context) are preserved.
### vMCP owns the reserved `_meta` namespace on both hops

vMCP is the client's MCP peer and the backends' MCP peer on two different hops,
so the reserved `io.modelcontextprotocol/*` `_meta` keys are vMCP's to own in
both directions. A single helper, `mcp.StripReservedMeta`, removes every key
under that prefix (except the end-to-end passthrough keys — `related-task`,
`model-immediate-response`) wherever a `_meta` map crosses vMCP:

- **Request egress** — before any Legacy backend call (`pkg/vmcp/client`,
`pkg/vmcp/session/internal/backend`) and on the Modern request path, which
overlays vMCP's own authoritative values afterwards. This is not cosmetic:
go-sdk v1.7 rejects **any** `_meta.protocolVersion` on a stateful
streamable-HTTP server outright (HTTP 400), regardless of its value.
- **Response/request egress to the client** — Legacy strips inside
`conversion.ToMCPMeta` (the funnel every Legacy egress crosses); Modern strips
in `newModernResultMeta` and re-stamps its own `serverInfo` last; the
elicitation adapter (a server→client request) crosses the same chokepoint.
This stops a backend fabricating the client's own identity (`serverInfo`,
`protocolVersion`, `clientInfo`).

Non-reserved caller/backend keys (progress tokens, W3C trace context) are
preserved throughout.

### Observability

Expand Down
133 changes: 91 additions & 42 deletions pkg/mcp/revision.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,30 +62,54 @@ const metaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities"
// hop, but — unlike the other reserved keys — its mere presence is NOT a claim
// of the Modern revision: go-sdk's validateRequestMeta gates Modern-ness purely
// on protocolVersion, and SEP-2577 already deprecates logLevel. It therefore
// belongs in the egress strip set (ReservedModernMetaKeys) but not the ingress
// signal set (modernSignalMetaKeys).
// belongs in the strip set (ReservedMetaPrefix, minus passthroughMetaKeys) but
// not the ingress signal set (modernSignalMetaKeys).
const metaKeyLogLevel = "io.modelcontextprotocol/logLevel"

// ReservedModernMetaKeys is the EGRESS/strip set: every reserved per-hop
// io.modelcontextprotocol/* control key that vMCP must remove before forwarding
// a caller-supplied _meta onto a Legacy (session-based, stateful) backend hop,
// where these keys are invalid — see StripReservedModernMeta.
// ReservedMetaPrefix is the _meta key namespace the MCP spec reserves for the
// protocol's own use. StripReservedMeta removes every key carrying it except
// the passthroughMetaKeys below.
//
// Exported so a Modern client (the mirror of the classifier that reads these
// keys) can strip a caller's copies before overlaying its own authoritative
// values — see ModernRequestMeta / mergeModernMeta.
// The trailing slash is load-bearing. The registry's
// "io.modelcontextprotocol.registry/publisher-provided" (docs/registry/schema.md)
// is ALSO spec-reserved — per the 2025-11-25 _meta key-name rules any prefix
// whose second label is "modelcontextprotocol" or "mcp" is reserved — but it is
// server-descriptor provenance, not per-hop control, and never rides a
// tools/call result. The slash is what keeps this predicate off it.
//
// This is deliberately NOT the Modern-detection set: that is
// modernSignalMetaKeys, a strict subset. "Strip this key on the way out" and
// "this key means the request is Modern" are separate decisions, because a
// reserved key can require the former without the latter (logLevel). Conflating
// them would make a request rejectable merely for carrying a
// strippable-but-not-signalling key.
var ReservedModernMetaKeys = []string{
metaKeyProtocolVersion,
metaKeyClientInfo,
metaKeyClientCapabilities,
metaKeyLogLevel,
// Matching one literal prefix deliberately does not implement that full
// second-label rule: "dev.mcp/", "org.modelcontextprotocol.api/" and friends are
// equally reserved and sail through. Nothing in this repo emits them and no
// threat model calls for parsing the general rule.
const ReservedMetaPrefix = "io.modelcontextprotocol/"

// passthroughMetaKeys are the reserved keys StripReservedMeta must NOT remove.
//
// The reserved namespace holds two different kinds of key, and only one of them
// is a gateway's to terminate:
//
// - per-hop protocol control (protocolVersion, clientInfo, clientCapabilities,
// logLevel, serverInfo, subscriptionId) — scoped to a single MCP connection.
// vMCP is its client's peer and its backends' peer on two different hops, so
// it must strip these and mint its own.
// - end-to-end semantic payload — meaningful to the ORIGINAL endpoints and
// merely carried by intermediaries. Stripping these breaks the feature.
//
// Both entries below are the second kind, from the 2025-11-25 task facility:
// "related-task" is a MUST on every task-related request AND response
// (tasks/result in particular carries the task id nowhere else), and
// "model-immediate-response" rides CreateTaskResult._meta. vMCP does not route
// tasks/* today, so these are inert — but this strip is what would silently
// break them later, and silent broken correlation is expensive to debug.
//
// Extend this by category, not by string: ask whether the key names something
// about THIS hop (strip) or about the two endpoints (pass through).
// A set, not a lookup table: struct{} values make membership the only question
// this map can answer. A map[string]bool would let a future "key": false entry
// read as "in the set" while silently stripping.
var passthroughMetaKeys = map[string]struct{}{
"io.modelcontextprotocol/related-task": {},
"io.modelcontextprotocol/model-immediate-response": {},
}

// modernSignalMetaKeys is the INGRESS/detection set consumed by hasModernSignal:
Expand All @@ -95,41 +119,66 @@ var ReservedModernMetaKeys = []string{
// protocolVersion alongside one of them turns into a rejection, never a
// downgrade.
//
// It is a strict subset of ReservedModernMetaKeys: logLevel is intentionally
// It is deliberately narrower than what StripReservedMeta removes: logLevel is
// excluded so a request carrying only logLevel — which go-sdk's
// validateRequestMeta accepts (gating purely on protocolVersion) and which
// SEP-2577 deprecates — is classified Legacy rather than misdetected as Modern
// and then rejected.
// and then rejected. "Strip this key on the way out" and "this key means the
// request is Modern" are separate decisions; conflating them would make a
// request rejectable merely for carrying a strippable-but-not-signalling key.
var modernSignalMetaKeys = []string{
metaKeyProtocolVersion,
metaKeyClientInfo,
metaKeyClientCapabilities,
}

// StripReservedModernMeta returns a copy of meta with every ReservedModernMetaKeys
// entry removed, leaving all other caller-supplied keys (including trace-context
// keys) untouched. The input is never mutated (maps.Clone).
// StripReservedMeta returns a copy of meta with every ReservedMetaPrefix key
// removed except the passthroughMetaKeys, leaving all other caller-supplied keys
// (progressToken, trace context, backend-custom fields) untouched. The input is
// never mutated (maps.Clone).
//
// Use it on BOTH hops, in both directions, wherever a _meta map crosses vMCP:
//
// Use this at every Legacy backend egress that forwards a caller-supplied _meta
// map: a downstream Modern request's reserved io.modelcontextprotocol/* _meta
// claims a per-request protocol version that is only valid on a stateless
// Modern hop. If it leaks onto a Legacy (session-based, stateful) backend call,
// go-sdk v1.7 rejects the request outright (HTTP 400: "protocol version ...
// is only supported on stateless HTTP servers") because ANY _meta.protocolVersion
// on a stateful streamable-HTTP server is invalid, regardless of its value. vMCP
// is the backend's actual MCP peer on this hop, not the downstream caller, so
// these reserved keys must never cross it.
// - Legacy backend egress (request): a downstream Modern caller's
// _meta.protocolVersion is only valid on a stateless Modern hop. go-sdk v1.7
// rejects the request outright (HTTP 400: "protocol version ... is only
// supported on stateless HTTP servers") for ANY _meta.protocolVersion on a
// stateful streamable-HTTP server, regardless of its value.
// - Modern backend egress (request): vMCP overlays its own authoritative
// values afterwards — see ModernRequestMeta / mergeModernMeta.
// - client egress (response, and server->client requests): a backend must not
// speak for vMCP. Only serverInfo is even schema-legal on a result
// (ResultMetaObject); the request-only keys arriving on one are a backend
// fabricating the client's own identity. The spec says nothing about what a
// gateway should forward, so this is derived rather than quoted: serverInfo
// identifies "the server software producing the response", vMCP is that
// software, therefore vMCP's value is the correct one.
//
// nil or empty input returns nil (matching mergeModernMeta's caller-tolerant
// convention); a non-empty map with none of the reserved keys present is
// returned as-is (via maps.Clone, so callers still get a copy, not the original).
func StripReservedModernMeta(meta map[string]any) map[string]any {
// TRIPWIRE: if vMCP ever relays resource subscriptions it must MINT its own
// io.modelcontextprotocol/subscriptionId from its own downstream listen request
// — never forward a backend's, which names a request id on the vMCP<->backend
// connection and collides across backends. That belongs in the notification
// relay (pkg/vmcp/client/forwarding.go), not here; this helper only sees
// requests and results. See pkg/transport/proxy/streamable/dispatcher_streams.go
// for the same concern in the streamable proxy.
//
// Returns nil whenever the result would be empty -- for nil or empty input, and
// also for a map whose keys were ALL reserved. Callers can therefore treat nil
// as "no _meta to send" without a second length check (mergeModernMeta's
// caller-tolerant convention, and what lets conversion.ToMCPMeta omit _meta
// entirely rather than emit an empty object). A map with keys left over is
// returned as a copy (maps.Clone), never the original.
func StripReservedMeta(meta map[string]any) map[string]any {
if len(meta) == 0 {
return nil
}
stripped := maps.Clone(meta)
for _, k := range ReservedModernMetaKeys {
delete(stripped, k)
maps.DeleteFunc(stripped, func(k string, _ any) bool {
_, passthrough := passthroughMetaKeys[k]
return strings.HasPrefix(k, ReservedMetaPrefix) && !passthrough
})
if len(stripped) == 0 {
return nil
}
return stripped
}
Expand Down Expand Up @@ -515,8 +564,8 @@ func metaFromParamsMap(paramsMap map[string]any) map[string]any {
// hasModernSignal reports whether the request signals the Modern revision:
// either the header exactly names MCPVersionModern, or _meta carries any of the
// modernSignalMetaKeys (regardless of whether their values are well-formed).
// It reads modernSignalMetaKeys, NOT ReservedModernMetaKeys — a key can be
// reserved-for-stripping without signalling Modern (logLevel).
// It reads modernSignalMetaKeys, NOT everything StripReservedMeta removes — a
// key can be reserved-for-stripping without signalling Modern (logLevel).
func hasModernSignal(meta map[string]any, protoHeader string) bool {
if protoHeader == MCPVersionModern {
return true
Expand Down
65 changes: 50 additions & 15 deletions pkg/mcp/revision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package mcp
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -215,7 +216,7 @@ func TestClassifyRevision(t *testing.T) {
// logLevel is a reserved key that must be STRIPPED on egress but is
// NOT a Modern signal (go-sdk's validateRequestMeta gates purely on
// protocolVersion; SEP-2577 deprecates logLevel). Guards the split
// between ReservedModernMetaKeys and modernSignalMetaKeys: if
// between what StripReservedMeta removes and modernSignalMetaKeys: if
// hasModernSignal ever iterated the strip set again, this request
// would be misdetected Modern and rejected instead of classified
// Legacy.
Expand Down Expand Up @@ -673,54 +674,88 @@ func TestEncodeSentinelName(t *testing.T) {
}
}

// TestStripReservedModernMeta pins the copy-before-mutate contract: it removes
// exactly the reserved io.modelcontextprotocol/* keys, never mutates the
// caller's map, and returns nil for empty input.
func TestStripReservedModernMeta(t *testing.T) {
// TestStripReservedMeta pins the copy-before-mutate contract and the two halves
// of the strip predicate: every ReservedMetaPrefix key goes EXCEPT the
// passthroughMetaKeys, and nothing outside the prefix is touched.
func TestStripReservedMeta(t *testing.T) {
t.Parallel()

t.Run("nil input returns nil", func(t *testing.T) {
t.Parallel()
assert.Nil(t, StripReservedModernMeta(nil))
assert.Nil(t, StripReservedMeta(nil))
})

t.Run("empty input returns nil", func(t *testing.T) {
t.Parallel()
assert.Nil(t, StripReservedModernMeta(map[string]any{}))
assert.Nil(t, StripReservedMeta(map[string]any{}))
})

t.Run("removes reserved keys, preserves the rest", func(t *testing.T) {
t.Parallel()
in := map[string]any{
// Request-side reserved keys.
metaKeyProtocolVersion: MCPVersionModern,
metaKeyClientInfo: map[string]any{"name": "x"},
metaKeyClientCapabilities: map[string]any{},
metaKeyLogLevel: "debug",
"progressToken": "tok-1",
"traceparent": "00-abc-def-01",
"custom": 42,
// Response/notification-side reserved keys: a backend must not be able
// to speak for vMCP on the way back to the client (#5986).
"io.modelcontextprotocol/serverInfo": map[string]any{"name": "attacker"},
"io.modelcontextprotocol/subscriptionId": "sub-1",
// An unknown future reserved key must go too -- that is the whole point
// of matching a namespace rather than a fixed list.
"io.modelcontextprotocol/futureThing": "whatever",
// Not reserved: ordinary caller/backend metadata.
"progressToken": "tok-1",
"traceparent": "00-abc-def-01",
"custom": 42,
// Reserved-adjacent, must NOT match: the second label differs, so the
// trailing slash in ReservedMetaPrefix is what saves it. This is
// registry provenance metadata, not per-hop control.
"io.modelcontextprotocol.registry/publisher-provided": map[string]any{"x": 1},
}
got := StripReservedModernMeta(in)
for _, k := range ReservedModernMetaKeys {
assert.NotContains(t, got, k, "reserved key %q must be stripped", k)
got := StripReservedMeta(in)

for k := range in {
if strings.HasPrefix(k, ReservedMetaPrefix) {
assert.NotContains(t, got, k, "reserved key %q must be stripped", k)
}
}
assert.Equal(t, "tok-1", got["progressToken"])
assert.Equal(t, "00-abc-def-01", got["traceparent"])
assert.Equal(t, 42, got["custom"])
assert.Contains(t, got, "io.modelcontextprotocol.registry/publisher-provided",
"the registry namespace is not this predicate's to strip")
})

t.Run("passthrough keys survive despite the reserved prefix", func(t *testing.T) {
t.Parallel()
// related-task is a 2025-11-25 MUST on task-related requests AND responses
// (tasks/result carries the task id nowhere else), so the strip must not
// eat it even though it sits under the reserved prefix.
in := map[string]any{
metaKeyProtocolVersion: MCPVersionModern,
"io.modelcontextprotocol/related-task": map[string]any{"taskId": "t-1"},
"io.modelcontextprotocol/model-immediate-response": true,
}
got := StripReservedMeta(in)
assert.NotContains(t, got, metaKeyProtocolVersion)
assert.Equal(t, map[string]any{"taskId": "t-1"}, got["io.modelcontextprotocol/related-task"])
assert.Equal(t, true, got["io.modelcontextprotocol/model-immediate-response"])
})

t.Run("does not mutate the caller's map", func(t *testing.T) {
t.Parallel()
in := map[string]any{metaKeyProtocolVersion: MCPVersionModern, "custom": 1}
_ = StripReservedModernMeta(in)
_ = StripReservedMeta(in)
assert.Contains(t, in, metaKeyProtocolVersion, "caller's map must be untouched")
assert.Len(t, in, 2)
})

t.Run("no reserved keys returns a copy, not the original", func(t *testing.T) {
t.Parallel()
in := map[string]any{"custom": 1}
got := StripReservedModernMeta(in)
got := StripReservedMeta(in)
require.Equal(t, in, got)
got["custom"] = 2
assert.Equal(t, 1, in["custom"], "returned value must be a copy")
Expand Down
9 changes: 7 additions & 2 deletions pkg/vmcp/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1706,8 +1706,13 @@ func (h *httpBackendClient) legacyCallTool(
// Legacy (session-based, stateful) backend: a downstream Modern caller's
// _meta.protocolVersion is only valid on a stateless Modern hop, and go-sdk
// v1.7 hard-rejects ANY _meta.protocolVersion on a stateful server (HTTP 400)
// regardless of its value — see mcpparser.StripReservedModernMeta.
meta = mcpparser.StripReservedModernMeta(meta)
// regardless of its value — see mcpparser.StripReservedMeta.
//
// conversion.ToMCPMeta below now strips too, so this is belt-and-suspenders;
// it stays because the HTTP-400 hazard is specific to this hop and deserves a
// guard at the site that knows why. (modernCallTool, which builds its _meta
// directly and never reaches ToMCPMeta, needs the helper outright.)
meta = mcpparser.StripReservedMeta(meta)
result, err := c.CallTool(ctx, mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: backendToolName,
Expand Down
6 changes: 3 additions & 3 deletions pkg/vmcp/client/modern.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,12 @@ func interpretModernResult(result json.RawMessage, rpcErr *modernRPCError, metho

// mergeModernMeta strips the reserved io.modelcontextprotocol/* keys from a
// caller-supplied _meta (if any) and overlays vMCP's authoritative values last.
// The caller's _meta is never mutated (StripReservedModernMeta clones it).
// The caller's _meta is never mutated (StripReservedMeta clones it).
func mergeModernMeta(callerMeta any) map[string]any {
m, _ := callerMeta.(map[string]any)
meta := mcpparser.StripReservedModernMeta(m)
meta := mcpparser.StripReservedMeta(m)
if meta == nil {
// StripReservedModernMeta returns nil for empty/nil input; this needs a
// StripReservedMeta returns nil for empty/nil input; this needs a
// non-nil map to overlay vMCP's authoritative values onto below.
meta = map[string]any{}
}
Expand Down
9 changes: 7 additions & 2 deletions pkg/vmcp/client/revision_realbackend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

Expand Down Expand Up @@ -156,8 +157,12 @@ func TestLegacyCallTool_StripsReservedMeta_RealBackend(t *testing.T) {
mu.Lock()
defer mu.Unlock()
require.True(t, sawCall, "the backend's tool handler must have been invoked")
for _, k := range mcpparser.ReservedModernMetaKeys {
assert.NotContains(t, gotMeta, k, "reserved Modern _meta key %q must be stripped before the Legacy hop", k)
// Assert on the namespace, not a fixed list: this catches any reserved key,
// including ones added to the fixture later. No passthroughMetaKeys entry is
// in play here, so a blanket prefix check is exact.
for k := range gotMeta {
assert.False(t, strings.HasPrefix(k, mcpparser.ReservedMetaPrefix),
"reserved _meta key %q must be stripped before the Legacy hop", k)
}
assert.Equal(t, "custom-value", gotMeta["custom-caller-key"], "non-reserved caller _meta must survive")
}
Expand Down
Loading
Loading