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
98 changes: 98 additions & 0 deletions pkg/vmcp/server/modern_capability_refusal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package server

import (
"context"
"sync"
)

// Client capability names as they appear in the draft schema's
// ClientCapabilities object — the keys a Modern client declares under
// _meta's io.modelcontextprotocol/clientCapabilities and the keys a
// MissingRequiredClientCapabilityError reports back in
// data.requiredCapabilities.
const (
capabilityElicitation = "elicitation"
capabilitySampling = "sampling"
)

// modernClientCapabilitiesKey is pkg/mcp's metaKeyClientCapabilities,
// reproduced by hand because that constant is unexported — the same precedent
// as modernServerInfoKey in modern_envelope.go. Keep the two in sync.
const modernClientCapabilitiesKey = "io.modelcontextprotocol/clientCapabilities"

// modernClientDeclaredCapability reports whether the request's _meta declared
// the named client capability. Presence of the key is the declaration (its
// value is a per-capability options object, typically {}), matching how
// ClassifyRevision treats clientCapabilities itself: presence, not content.
func modernClientDeclaredCapability(meta map[string]any, capability string) bool {
caps, _ := meta[modernClientCapabilitiesKey].(map[string]any)
_, ok := caps[capability]
return ok
}

// capabilityRefusalRecorder captures, in-process, which client capability a
// backend's mid-call server-initiated request needed when there was no
// downstream session to forward it to (Modern ingress). dispatchModern's call
// verbs install one before dispatching; the SDK requester adapters
// (sdk_elicitation_adapter.go, sdk_sampling_adapter.go) record into it at the
// exact point of refusal (mcpcompat's ErrNoActiveSession); the dispatcher
// reads it after a failed call to classify the failure as the draft schema's
// MissingRequiredClientCapabilityError (-32021) when the client did not
// declare the capability.
//
// Why this rides the context rather than a parameter or the error chain: the
// refusal evidence cannot travel back through the return path. The forwarder
// answers the BACKEND's elicitation/sampling request; the backend then fails
// its own tool, and what the dispatcher receives is the backend's error
// STRING — the typed sentinel is laundered away at the wire boundary. A
// Modern request has no MultiSession to hang per-call state on (that absence
// is the very thing being recorded), and the requesters are bound once at
// startup (BindForwarders), so a per-call explicit parameter cannot reach
// them either. This mirrors the audit.BackendInfoFromContext pattern the same
// dispatch verbs already use: a transport-boundary observation channel
// written by a lower layer, not domain data flowing between middleware
// (vmcp-anti-patterns #1 targets the latter).
type capabilityRefusalRecorder struct {
mu sync.Mutex
capability string
}

// capabilityRefusalKey is the context key for the recorder.
type capabilityRefusalKey struct{}

// withCapabilityRefusalRecorder returns ctx carrying a fresh recorder, plus
// the recorder itself for the caller to read after dispatch. Installed only
// by dispatchModern's call verbs; on the Legacy/SDK path no recorder exists
// and recordCapabilityRefusal is a benign no-op (Legacy calls have a session,
// so the refusal it observes cannot occur there in the first place).
func withCapabilityRefusalRecorder(ctx context.Context) (context.Context, *capabilityRefusalRecorder) {
rec := &capabilityRefusalRecorder{}
return context.WithValue(ctx, capabilityRefusalKey{}, rec), rec
}

// recordCapabilityRefusal notes that a mid-call server-initiated request
// needing the named capability was refused for lack of a downstream session.
// The first recorded capability wins — one call verb produces one wire error,
// and the first refusal is what failed the backend tool. No-op when ctx
// carries no recorder.
func recordCapabilityRefusal(ctx context.Context, capability string) {
rec, _ := ctx.Value(capabilityRefusalKey{}).(*capabilityRefusalRecorder)
if rec == nil {
return
}
rec.mu.Lock()
defer rec.mu.Unlock()
if rec.capability == "" {
rec.capability = capability
}
}

// refused returns the recorded capability name, or "" if no refusal occurred.
func (r *capabilityRefusalRecorder) refused() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.capability
}
155 changes: 155 additions & 0 deletions pkg/vmcp/server/modern_capability_refusal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package server

import (
"context"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stacklok/toolhive-core/mcpcompat/server"
"github.com/stacklok/toolhive/pkg/vmcp"
)

func TestModernClientDeclaredCapability(t *testing.T) {
t.Parallel()

tests := []struct {
name string
meta map[string]any
capability string
want bool
}{
{
name: "nil meta",
meta: nil,
capability: capabilityElicitation,
want: false,
},
{
name: "no clientCapabilities key",
meta: map[string]any{"other": "x"},
capability: capabilityElicitation,
want: false,
},
{
name: "clientCapabilities not an object",
meta: map[string]any{modernClientCapabilitiesKey: "elicitation"},
capability: capabilityElicitation,
want: false,
},
{
name: "empty declaration",
meta: map[string]any{modernClientCapabilitiesKey: map[string]any{}},
capability: capabilityElicitation,
want: false,
},
{
name: "declared with empty options object",
meta: map[string]any{modernClientCapabilitiesKey: map[string]any{
capabilityElicitation: map[string]any{},
}},
capability: capabilityElicitation,
want: true,
},
{
name: "different capability declared",
meta: map[string]any{modernClientCapabilitiesKey: map[string]any{
capabilitySampling: map[string]any{},
}},
capability: capabilityElicitation,
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, modernClientDeclaredCapability(tc.meta, tc.capability))
})
}
}

func TestCapabilityRefusalRecorder(t *testing.T) {
t.Parallel()

t.Run("no recorder in context is a no-op", func(t *testing.T) {
t.Parallel()
// Must not panic; there is simply nothing to observe.
recordCapabilityRefusal(context.Background(), capabilityElicitation)
})

t.Run("records and reads back", func(t *testing.T) {
t.Parallel()
ctx, rec := withCapabilityRefusalRecorder(context.Background())
assert.Empty(t, rec.refused())
recordCapabilityRefusal(ctx, capabilitySampling)
assert.Equal(t, capabilitySampling, rec.refused())
})

t.Run("first recorded capability wins", func(t *testing.T) {
t.Parallel()
ctx, rec := withCapabilityRefusalRecorder(context.Background())
recordCapabilityRefusal(ctx, capabilityElicitation)
recordCapabilityRefusal(ctx, capabilitySampling)
assert.Equal(t, capabilityElicitation, rec.refused())
})
}

// TestSDKAdapters_RecordCapabilityRefusal verifies both SDK requester adapters
// record the refusal into a context recorder exactly when the mcpcompat SDK
// refuses for lack of a session (ErrNoActiveSession) — and never for any other
// error, which would misclassify e.g. a client decline as a capability gap.
func TestSDKAdapters_RecordCapabilityRefusal(t *testing.T) {
t.Parallel()

tests := []struct {
name string
sdkErr error
wantRecorded bool // per-adapter capability name asserted below
}{
{
name: "no active session records the capability",
sdkErr: server.ErrNoActiveSession,
wantRecorded: true,
},
{
name: "wrapped no active session records too",
sdkErr: errors.Join(errors.New("outer"), server.ErrNoActiveSession),
wantRecorded: true,
},
{
name: "other errors do not record",
sdkErr: errors.New("client declined"),
wantRecorded: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

elicit := &sdkElicitationAdapter{mcpServer: &fakeSDKElicitationRequester{err: tc.sdkErr}}
ctx, rec := withCapabilityRefusalRecorder(context.Background())
_, err := elicit.RequestElicitation(ctx, vmcp.ElicitationRequest{Message: "m"})
require.Error(t, err, "the SDK error must still propagate unchanged")
if tc.wantRecorded {
assert.Equal(t, capabilityElicitation, rec.refused())
} else {
assert.Empty(t, rec.refused())
}

sample := &sdkSamplingAdapter{mcpServer: &fakeSDKSamplingRequester{err: tc.sdkErr}}
ctx, rec = withCapabilityRefusalRecorder(context.Background())
_, err = sample.RequestSampling(ctx, vmcp.SamplingRequest{})
require.Error(t, err, "the SDK error must still propagate unchanged")
if tc.wantRecorded {
assert.Equal(t, capabilitySampling, rec.refused())
} else {
assert.Empty(t, rec.refused())
}
})
}
}
102 changes: 99 additions & 3 deletions pkg/vmcp/server/modern_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"

Expand Down Expand Up @@ -220,9 +221,13 @@ func (s *Server) dispatchModernToolCall(
writeModernDenied(w, parsed.ID, vmcp.DenyMessageToolCall)
return
}
// The refusal recorder classifies a backend tool that demanded mid-call
// elicitation/sampling with no downstream session to forward it to — see
// modern_capability_refusal.go and writeModernCallFailure.
ctx, refusal := withCapabilityRefusalRecorder(ctx)
result, err := s.core.CallTool(ctx, identity, parsed.ResourceID, parsed.Arguments, parsed.Meta)
if err != nil {
writeModernDispatchError(w, parsed.ID, vmcp.DenyMessageToolCall, err)
writeModernCallFailure(w, parsed, refusal, vmcp.DenyMessageToolCall, err)
return
}
// Label the audit backend on the success path only. The stateless dispatcher
Expand All @@ -249,9 +254,10 @@ func (s *Server) dispatchModernResourceRead(
writeModernDenied(w, parsed.ID, vmcp.DenyMessageResourceRead)
return
}
ctx, refusal := withCapabilityRefusalRecorder(ctx)
result, err := s.core.ReadResource(ctx, identity, parsed.ResourceID)
if err != nil {
writeModernDispatchError(w, parsed.ID, vmcp.DenyMessageResourceRead, err)
writeModernCallFailure(w, parsed, refusal, vmcp.DenyMessageResourceRead, err)
return
}
if result.BackendID != "" {
Expand Down Expand Up @@ -281,9 +287,10 @@ func (s *Server) dispatchModernPromptGet(
writeModernDenied(w, parsed.ID, vmcp.DenyMessagePromptGet)
return
}
ctx, refusal := withCapabilityRefusalRecorder(ctx)
result, err := s.core.GetPrompt(ctx, identity, parsed.ResourceID, parsed.Arguments)
if err != nil {
writeModernDispatchError(w, parsed.ID, vmcp.DenyMessagePromptGet, err)
writeModernCallFailure(w, parsed, refusal, vmcp.DenyMessagePromptGet, err)
return
}
if result.BackendID != "" {
Expand Down Expand Up @@ -426,6 +433,95 @@ func writeModernListError(ctx context.Context, w http.ResponseWriter, id any, me
writeModernError(w, id, jsonRPCCodeInternalError, "internal error")
}

// writeModernCallFailure classifies a POST-dispatch error from the three call
// verbs (tools/call, resources/read, prompts/get), layering the mid-call
// capability-refusal case on top of writeModernDispatchError's authz/generic
// classification:
//
// - Authorization denials keep absolute priority (403 + denyMsg, so the
// audit middleware still logs "denied") — a refusal can never mask one.
// - A recorded refusal with the capability NOT declared in the request's
// _meta clientCapabilities is the draft schema's
// MissingRequiredClientCapabilityError: code -32021 with
// data.requiredCapabilities naming what the server needed (an actionable
// code a client can surface, where -32603 is opaque) — served at HTTP
// 200, a deliberate, documented deviation from the spec-mandated 400;
// see writeModernMissingCapability.
// - A recorded refusal with the capability DECLARED is deliberately NOT
// -32021: the client did declare it, so blaming the client would be
// wrong, and declaring the capability on a retry cannot help. The spec's answer for a declared
// capability is resultType "input_required" (multi-round retrieval,
// SEP-2322), which this dispatcher does not implement — and the
// 2026-07-28 error vocabulary has no "operation not supported" code, so
// there is no conformant code for this case at all (see the "unavailable
// to Modern clients" limitation in
// docs/arch/10-virtual-mcp-architecture.md). It stays -32603, with a
// vMCP-owned message that names the real cause instead of the backend's
// laundered error string.
//
// Do not fold this into writeModernDispatchError: completion/complete also
// uses that helper but never installs a refusal recorder (SEP-2322 does not
// extend to completion/complete, so an input_required-shaped failure there
// has no spec-defined classification).
func writeModernCallFailure(
w http.ResponseWriter, parsed *mcpparser.ParsedMCPRequest, refusal *capabilityRefusalRecorder,
denyMsg string, err error,
) {
if capName := refusal.refused(); capName != "" && !errors.Is(err, vmcp.ErrAuthorizationFailed) {
if !modernClientDeclaredCapability(parsed.Meta, capName) {
writeModernMissingCapability(w, parsed.ID, capName)
return
}
writeModernError(w, parsed.ID, jsonRPCCodeInternalError, fmt.Sprintf(
"backend requires the %q client capability mid-call; serving it on protocol %s requires "+
"multi-round retrieval (SEP-2322), which this server does not implement",
capName, mcpparser.MCPVersionModern))
return
}
writeModernDispatchError(w, parsed.ID, denyMsg, err)
}

// writeModernMissingCapability writes the MissingRequiredClientCapabilityError
// (-32021, data.requiredCapabilities typed as a ClientCapabilities object) at
// HTTP 200.
//
// STATUS DEVIATION, deliberate and load-bearing: SEP-2575 MUSTs HTTP 400 for
// this error, but go-sdk's streamable client (v1.7.0-pre.3) treats any
// non-transient 4xx as a CONNECTION failure, not a call failure — its
// transient set is only 500/502/503/504/429, so a 400 falls through
// checkResponse to fail(), which closes the session permanently. One refused
// elicitation would kill every subsequent request on the client. The JSON-RPC
// body below stays fully conformant (which is what a non-go-sdk client
// parses); only the transport status deviates. Tracked upstream as
// go-sdk#1117 — revisit the 200 when that is fixed.
//
// This is written directly rather than through mcpparser.WriteClassificationError
// on purpose: that helper hard-codes HTTP 400, which is correct for its other
// callers (pre-dispatch classification rejections a client never retries on a
// live session) and must not be changed for this one.
//
// The message names both the capability AND the gateway limitation, so a
// caller that reacts to -32021 by declaring the capability on a retry
// learns immediately that doing so will not help here (the declared case is served by
// writeModernCallFailure's -32603 branch, not by MRTR).
func writeModernMissingCapability(w http.ResponseWriter, id any, capName string) {
writeModernEnvelope(w, http.StatusOK, map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{
"code": mcpparser.CodeMissingClientCapability,
"message": fmt.Sprintf(
"this tool requires the %q client capability mid-call, which the request did not declare; "+
"note that declaring it will not help on protocol %s — serving mid-call capability "+
"requests needs multi-round retrieval (SEP-2322), which this server does not implement",
capName, mcpparser.MCPVersionModern),
"data": map[string]any{
"requiredCapabilities": map[string]any{capName: map[string]any{}},
},
},
})
}

// writeModernDispatchError classifies a POST-dispatch error from
// CallTool/ReadResource/GetPrompt. Check* and the real call each re-aggregate
// independently (documented "aggregates twice" on CheckToolCall), so a
Expand Down
Loading
Loading