Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3405,6 +3405,11 @@ spec:
check
format: date-time
type: string
mcpRevision:
description: |-
MCPRevision is the backend's negotiated MCP protocol revision
("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed.
type: string
message:
description: Message provides additional information about the
backend status
Expand Down Expand Up @@ -6847,6 +6852,11 @@ spec:
check
format: date-time
type: string
mcpRevision:
description: |-
MCPRevision is the backend's negotiated MCP protocol revision
("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed.
type: string
message:
description: Message provides additional information about the
backend status
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3408,6 +3408,11 @@ spec:
check
format: date-time
type: string
mcpRevision:
description: |-
MCPRevision is the backend's negotiated MCP protocol revision
("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed.
type: string
message:
description: Message provides additional information about the
backend status
Expand Down Expand Up @@ -6850,6 +6855,11 @@ spec:
check
format: date-time
type: string
mcpRevision:
description: |-
MCPRevision is the backend's negotiated MCP protocol revision
("2026-07-28" or "2025-11-25"). Empty when the backend has not been probed.
type: string
message:
description: Message provides additional information about the
backend status
Expand Down
46 changes: 43 additions & 3 deletions pkg/mcp/revision.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ const (
// build understands.
const MCPVersionModern = "2026-07-28"

// MCPVersionLegacy is the single Legacy (session-based) protocol version this
// build understands. It is what RevisionLegacy names on the wire and the one
// version mcpcompat's initialize handshake negotiates.
const MCPVersionLegacy = "2025-11-25"

// String returns the wire protocol-version string for the revision:
// MCPVersionModern for RevisionModern, MCPVersionLegacy otherwise.
func (r Revision) String() string {
if r == RevisionModern {
return MCPVersionModern
}
return MCPVersionLegacy
}

// metaKeyProtocolVersion is the reserved _meta key that carries the per-request
// protocol version on Modern (stateless) MCP requests, per the draft MCP schema's
// RequestMetaObject.
Expand All @@ -41,12 +55,30 @@ const metaKeyClientInfo = "io.modelcontextprotocol/clientInfo"
// schema's RequestMetaObject.
const metaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities"

// reservedModernMetaKeys are the _meta keys a Legacy client never sets. The
// ReservedModernMetaKeys are the _meta keys a Legacy client never sets. The
// presence of any one of them — independent of whether its value is
// well-formed — is itself a claim of the Modern revision, and must not be
// silently downgraded to Legacy. Only a malformed/absent protocolVersion
// alongside one of these keys turns into a rejection, never a downgrade.
var reservedModernMetaKeys = []string{metaKeyProtocolVersion, metaKeyClientInfo, metaKeyClientCapabilities}
//
// Exported so a Modern client (which sets these keys, the mirror of the
// classifier that reads them) can strip a caller's copies before overlaying
// its own authoritative values — see ModernRequestMeta.
var ReservedModernMetaKeys = []string{metaKeyProtocolVersion, metaKeyClientInfo, metaKeyClientCapabilities}

// ModernRequestMeta builds the reserved _meta object every Modern (2026-07-28)
// request must carry: protocolVersion, clientInfo, and (empty) clientCapabilities.
// It is the single source of truth for the client side of the reserved
// io.modelcontextprotocol/* keys, kept consistent with what ClassifyRevision and
// ValidateHeaderConsistency require of the server side — protocolVersion equal to
// MCPVersionModern and clientCapabilities present as a JSON object.
func ModernRequestMeta(clientName, clientVersion string) map[string]any {
return map[string]any{
metaKeyProtocolVersion: MCPVersionModern,
metaKeyClientInfo: map[string]any{"name": clientName, "version": clientVersion},
metaKeyClientCapabilities: map[string]any{},
}
}

// The following JSON-RPC error codes are defined by the draft MCP spec
// (schema/draft/schema.ts) for the stateless "Modern" revision. They are
Expand Down Expand Up @@ -325,6 +357,14 @@ var nameRequiredMethods = map[string]bool{
"prompts/get": true,
}

// IsNameRequiredMethod reports whether the Modern (2026-07-28) method requires
// an Mcp-Name request header naming the target tool/resource/prompt. It shares
// nameRequiredMethods with ValidateHeaderConsistency so a Modern client sets the
// header for exactly the methods the server validates it on.
func IsNameRequiredMethod(method string) bool {
return nameRequiredMethods[method]
}

// ValidateHeaderConsistency enforces the Modern (2026-07-28) Mcp-Method and
// Mcp-Name request headers against the corresponding parsed request body
// fields (Method and ResourceID).
Expand Down Expand Up @@ -412,7 +452,7 @@ func hasModernSignal(meta map[string]any, protoHeader string) bool {
if protoHeader == MCPVersionModern {
return true
}
for _, key := range reservedModernMetaKeys {
for _, key := range ReservedModernMetaKeys {
if _, ok := meta[key]; ok {
return true
}
Expand Down
157 changes: 83 additions & 74 deletions pkg/vmcp/client/auth_error_mapping_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/stacklok/toolhive-core/mcpcompat/client"
mcptransport "github.com/stacklok/toolhive-core/mcpcompat/client/transport"
mcpparser "github.com/stacklok/toolhive/pkg/mcp"
"github.com/stacklok/toolhive/pkg/vmcp"
)

Expand Down Expand Up @@ -58,20 +59,19 @@ func TestRegression_401_MapsToErrAuthenticationFailed(t *testing.T) {
}))
t.Cleanup(srv.Close)

h := &httpBackendClient{
clientFactory: func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
},
h := newProbeClient(t)
h.clientFactory = func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
}

target := &vmcp.BackendTarget{
Expand All @@ -81,6 +81,10 @@ func TestRegression_401_MapsToErrAuthenticationFailed(t *testing.T) {
TransportType: "streamable-http",
}

// Pre-seed Legacy so ListCapabilities skips the Modern discover probe (which
// needs a real registry) and exercises the Legacy error-mapping path here.
h.setRevision(target.WorkloadID, mcpparser.RevisionLegacy)

_, err := h.ListCapabilities(context.Background(), target)
require.Error(t, err)
assert.True(t, errors.Is(err, vmcp.ErrAuthenticationFailed),
Expand All @@ -90,12 +94,12 @@ func TestRegression_401_MapsToErrAuthenticationFailed(t *testing.T) {
// TestRegression_403OnInitialize_LegacySSEFallback verifies that a backend
// returning HTTP 403 on initialize is classified as ErrBackendUnavailable.
//
// NOTE: The mcp-go streamable-HTTP transport returns a generic HTTP error for
// 403 ("request failed with status 403"), not transport.ErrLegacySSEServer.
// The "legacy SSE" hint in wrapBackendError is only added when the origin error
// IS transport.ErrLegacySSEServer (returned by SSE transport, not streamable-HTTP).
// For streamable-HTTP, 403 falls through to string-based classification and
// correctly maps to ErrBackendUnavailable, but without the SSE-specific message.
// NOTE: a 4xx (except 401) on the initialize POST surfaces as
// transport.ErrLegacySSEServer (see wrapBackendError), which wrapBackendError maps
// to ErrBackendUnavailable while preserving the sentinel in the chain (see
// TestRegression_403OnInitialize_PreservesSentinel). Because 403 is ambiguous
// (auth rejection vs Modern-only backend), it drives a re-probe that returns the
// same revision here and surfaces this error unchanged.
func TestRegression_403OnInitialize_LegacySSEFallback(t *testing.T) {
t.Parallel()

Expand All @@ -109,20 +113,19 @@ func TestRegression_403OnInitialize_LegacySSEFallback(t *testing.T) {
}))
t.Cleanup(srv.Close)

h := &httpBackendClient{
clientFactory: func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
},
h := newProbeClient(t)
h.clientFactory = func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
}

target := &vmcp.BackendTarget{
Expand All @@ -132,6 +135,8 @@ func TestRegression_403OnInitialize_LegacySSEFallback(t *testing.T) {
TransportType: "streamable-http",
}

h.setRevision(target.WorkloadID, mcpparser.RevisionLegacy)

_, err := h.ListCapabilities(context.Background(), target)
require.Error(t, err)
assert.True(t, errors.Is(err, vmcp.ErrBackendUnavailable),
Expand All @@ -140,14 +145,14 @@ func TestRegression_403OnInitialize_LegacySSEFallback(t *testing.T) {
"error message should reference 403 status, got: %v", err)
}

// TestRegression_403OnInitialize_MatchesSentinel verifies that
// transport.ErrLegacySSEServer is NOT in the error chain for 403 on
// initialize, because wrapBackendError uses %v (not %w) for the
// original error, AND the mcp-go streamable-HTTP transport does not
// return ErrLegacySSEServer for 403 (it returns a generic HTTP error).
// Regardless of which error type is at the origin, the sentinel should
// never be in the chain.
func TestRegression_403OnInitialize_MatchesSentinel(t *testing.T) {
// TestRegression_403OnInitialize_PreservesSentinel verifies that a 403 on
// initialize still classifies as ErrBackendUnavailable AND that the origin
// transport.ErrLegacySSEServer is preserved in the error chain (wrapBackendError
// now uses a second %w). The preserved sentinel is what lets dispatch detect a
// revision mismatch — a 403 is ambiguous (auth rejection vs Modern-only backend),
// so it drives a re-probe that returns the same revision here (Legacy) and
// surfaces the original error unchanged.
func TestRegression_403OnInitialize_PreservesSentinel(t *testing.T) {
t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
Expand All @@ -160,20 +165,19 @@ func TestRegression_403OnInitialize_MatchesSentinel(t *testing.T) {
}))
t.Cleanup(srv.Close)

h := &httpBackendClient{
clientFactory: func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
},
h := newProbeClient(t)
h.clientFactory = func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
}

target := &vmcp.BackendTarget{
Expand All @@ -183,13 +187,17 @@ func TestRegression_403OnInitialize_MatchesSentinel(t *testing.T) {
TransportType: "streamable-http",
}

h.setRevision(target.WorkloadID, mcpparser.RevisionLegacy)

_, err := h.ListCapabilities(context.Background(), target)
require.Error(t, err)

// wrapBackendError uses %v for the original error, so
// transport.ErrLegacySSEServer is NOT in the chain.
assert.False(t, errors.Is(err, mcptransport.ErrLegacySSEServer),
"transport.ErrLegacySSEServer should NOT be in the error chain (wrapBackendError uses %v)")
assert.ErrorIs(t, err, vmcp.ErrBackendUnavailable,
"403 on initialize must still classify as backend unavailable")
// wrapBackendError now preserves the origin via a second %w, so the sentinel
// is in the chain — this is what enables revision-mismatch detection.
assert.ErrorIs(t, err, mcptransport.ErrLegacySSEServer,
"transport.ErrLegacySSEServer must be preserved in the error chain (wrapBackendError uses %w)")
}

// TestRegression_BackendToolErrorWith401_NotClassifiedAsAuthFailure verifies
Expand Down Expand Up @@ -261,20 +269,19 @@ func TestRegression_BackendToolErrorWith401_NotClassifiedAsAuthFailure(t *testin
}))
t.Cleanup(srv.Close)

h := &httpBackendClient{
clientFactory: func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
},
h := newProbeClient(t)
h.clientFactory = func(ctx context.Context, target *vmcp.BackendTarget, _ bool) (*client.Client, error) {
c, err := client.NewStreamableHttpClient(
target.BaseURL,
mcptransport.WithHTTPTimeout(30*time.Second),
)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
return c, nil
}

target := &vmcp.BackendTarget{
Expand All @@ -284,6 +291,8 @@ func TestRegression_BackendToolErrorWith401_NotClassifiedAsAuthFailure(t *testin
TransportType: "streamable-http",
}

h.setRevision(target.WorkloadID, mcpparser.RevisionLegacy)

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

Expand Down
Loading
Loading