From 14e66a78ad562a77666658b2152626b48e9e2155 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 27 Jul 2026 20:53:28 +0000 Subject: [PATCH 1/2] Classify Modern mid-call capability refusals per spec When a backend tool demands mid-call elicitation or sampling during a Modern (2026-07-28) client's call, there is no downstream session to forward it to, and the failure surfaced as an opaque -32603 carrying the backend's laundered error string - including the backend workload ID. Per the draft schema, the honest answer when the client did not declare the capability is MissingRequiredClientCapabilityError: code -32021 with data.requiredCapabilities naming what the server needed. When the client DID declare it, -32021 would wrongly blame the client (and re-declaring cannot help), and the 2026-07-28 vocabulary has no "operation not supported" code - so that case stays -32603 with a vMCP-owned message naming multi-round retrieval (SEP-2322) as the gateway limitation. Both messages are crafted, closing the workload-ID leak on this path. The -32021 is served at HTTP 200, a deliberate, documented deviation from the spec-mandated 400: go-sdk's streamable client treats a non-transient 4xx as a connection failure and permanently fails the session (its transient set is only 500/502/503/504/429), so one refused elicitation would kill every subsequent request. The JSON-RPC body stays fully conformant; tracked upstream as go-sdk#1117. The refusal evidence cannot ride the error chain - the typed ErrNoActiveSession is flattened into a plain string at the wire boundary when the forwarder answers the backend and the backend fails its tool - so an in-process recorder (mirroring the audit.BackendInfo transport-boundary pattern) is installed by the three Modern call verbs and written by the SDK requester adapters at the exact point of refusal. A client DECLINE never records: misclassifying a user's "no" as a capability gap would be wrong, so only ErrNoActiveSession does. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam --- pkg/vmcp/server/modern_capability_refusal.go | 98 +++++++++++ .../server/modern_capability_refusal_test.go | 155 ++++++++++++++++++ pkg/vmcp/server/modern_dispatch.go | 102 +++++++++++- .../modern_realbackend_integration_test.go | 92 ++++++++++- pkg/vmcp/server/sdk_elicitation_adapter.go | 13 ++ pkg/vmcp/server/sdk_sampling_adapter.go | 6 + 6 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 pkg/vmcp/server/modern_capability_refusal.go create mode 100644 pkg/vmcp/server/modern_capability_refusal_test.go diff --git a/pkg/vmcp/server/modern_capability_refusal.go b/pkg/vmcp/server/modern_capability_refusal.go new file mode 100644 index 0000000000..aca5d127f8 --- /dev/null +++ b/pkg/vmcp/server/modern_capability_refusal.go @@ -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 +} diff --git a/pkg/vmcp/server/modern_capability_refusal_test.go b/pkg/vmcp/server/modern_capability_refusal_test.go new file mode 100644 index 0000000000..88fa3769fc --- /dev/null +++ b/pkg/vmcp/server/modern_capability_refusal_test.go @@ -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()) + } + }) + } +} diff --git a/pkg/vmcp/server/modern_dispatch.go b/pkg/vmcp/server/modern_dispatch.go index d705fb8f14..52ad12dbc5 100644 --- a/pkg/vmcp/server/modern_dispatch.go +++ b/pkg/vmcp/server/modern_dispatch.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" @@ -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 @@ -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 != "" { @@ -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 != "" { @@ -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 re-declaring 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 re-declaring learns immediately that +// re-declaring 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 diff --git a/pkg/vmcp/server/modern_realbackend_integration_test.go b/pkg/vmcp/server/modern_realbackend_integration_test.go index 9514c3228a..58828f44c5 100644 --- a/pkg/vmcp/server/modern_realbackend_integration_test.go +++ b/pkg/vmcp/server/modern_realbackend_integration_test.go @@ -55,7 +55,13 @@ func postModern( meta = map[string]any{} } meta["io.modelcontextprotocol/protocolVersion"] = "2026-07-28" - meta["io.modelcontextprotocol/clientCapabilities"] = map[string]any{} + // clientCapabilities is required on every Modern request; default to the + // empty declaration, but preserve a caller-supplied value so tests can + // exercise declared-capability behavior (see + // TestIntegration_Modern_RealBackend_MidCallCapabilityContract). + if _, ok := meta["io.modelcontextprotocol/clientCapabilities"]; !ok { + meta["io.modelcontextprotocol/clientCapabilities"] = map[string]any{} + } params["_meta"] = meta body := map[string]any{ @@ -321,3 +327,87 @@ func TestIntegration_Modern_RealBackend_MalformedArguments(t *testing.T) { require.True(t, ok, "decoded: %+v", decoded) assert.EqualValues(t, -32602, errObj["code"]) } + +// TestIntegration_Modern_RealBackend_MidCallCapabilityContract pins the +// two-path error contract for a backend tool that demands mid-call +// elicitation/sampling during a Modern client's tools/call +// (writeModernCallFailure): +// +// - capability NOT declared in _meta clientCapabilities: the draft schema's +// MissingRequiredClientCapabilityError — code -32021 with +// data.requiredCapabilities typed as a ClientCapabilities object — served +// at HTTP 200, a deliberate, documented deviation from the spec-mandated +// 400 (go-sdk treats a non-transient 4xx as permanent connection death; +// see writeModernMissingCapability and go-sdk#1117). +// - capability DECLARED: -32021 would wrongly blame the client, and the +// 2026-07-28 vocabulary has no "operation not supported" code, so it is +// an explicit -32603 whose vMCP-owned message names the real cause: +// honouring a declared capability mid-call is multi-round retrieval +// (SEP-2322), which this server does not implement. +// +// Both paths emit vMCP-crafted messages, so — unlike the raw backend error +// chain they replace — they are asserted on ToolHive-owned strings, and the +// backend workload ID must NOT leak into them. +func TestIntegration_Modern_RealBackend_MidCallCapabilityContract(t *testing.T) { + t.Parallel() + + backendURL := startForwardingBackend(t) + ts := newRealModernTestServer(t, backendURL) + + tests := []struct { + name string + tool string + capability string // the capability the backend's tool demands mid-call + declared bool // whether the request's _meta declares it + }{ + {name: "elicitation undeclared", tool: fwdElicitTool, capability: "elicitation", declared: false}, + {name: "sampling undeclared", tool: fwdSampleTool, capability: "sampling", declared: false}, + {name: "elicitation declared", tool: fwdElicitTool, capability: "elicitation", declared: true}, + {name: "sampling declared", tool: fwdSampleTool, capability: "sampling", declared: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + params := map[string]any{"name": tc.tool} + if tc.declared { + params["_meta"] = map[string]any{ + "io.modelcontextprotocol/clientCapabilities": map[string]any{ + tc.capability: map[string]any{}, + }, + } + } + resp, decoded := postModern(t, ts.URL, "tools/call", params, 1, tc.tool) + defer resp.Body.Close() + + // Both paths ride HTTP 200: -32603 per writeModernError's mapping, + // -32021 per writeModernMissingCapability's documented deviation. + require.Equal(t, http.StatusOK, resp.StatusCode, "decoded: %+v", decoded) + require.NotContains(t, decoded, "result", + "must not fabricate a success or an input_required envelope: %+v", decoded) + errObj, ok := decoded["error"].(map[string]any) + require.True(t, ok, "decoded: %+v", decoded) + msg, _ := errObj["message"].(string) + + // vMCP owns both messages: they must name the capability and the + // gateway limitation (SEP-2322), and must not leak the backend + // workload ID the raw error chain used to carry. + assert.Contains(t, msg, tc.capability) + assert.Contains(t, msg, "SEP-2322", + "the message must name multi-round retrieval as the gateway limitation") + assert.NotContains(t, msg, "real-backend", + "the crafted message must not leak the backend workload ID") + + if !tc.declared { + assert.EqualValues(t, -32021, errObj["code"]) + data, ok := errObj["data"].(map[string]any) + require.True(t, ok, "decoded: %+v", decoded) + required, ok := data["requiredCapabilities"].(map[string]any) + require.True(t, ok, "decoded: %+v", decoded) + assert.Contains(t, required, tc.capability, + "requiredCapabilities must name the capability the server needed") + return + } + assert.EqualValues(t, -32603, errObj["code"]) + }) + } +} diff --git a/pkg/vmcp/server/sdk_elicitation_adapter.go b/pkg/vmcp/server/sdk_elicitation_adapter.go index b699aa5ed7..9ed38864e3 100644 --- a/pkg/vmcp/server/sdk_elicitation_adapter.go +++ b/pkg/vmcp/server/sdk_elicitation_adapter.go @@ -7,6 +7,7 @@ package server import ( "context" + "errors" "github.com/stacklok/toolhive-core/mcpcompat/mcp" "github.com/stacklok/toolhive-core/mcpcompat/server" @@ -113,6 +114,18 @@ func (a *sdkElicitationAdapter) RequestElicitation( // We don't need to manage any of this - it's all handled by the SDK. resp, err := a.mcpServer.RequestElicitation(ctx, mcpReq) if err != nil { + // A refusal for lack of a downstream session (Modern ingress: the + // stateless dispatch installed no ClientSession) is recorded so + // dispatchModern can classify the resulting call failure as a + // -32021 MissingRequiredClientCapabilityError instead of an opaque + // internal error. The error itself still propagates unchanged: it is + // the answer to the BACKEND's elicitation request, and the typed + // sentinel does not survive that wire round-trip — the recorder is + // the only channel back to the dispatcher (see + // modern_capability_refusal.go). + if errors.Is(err, server.ErrNoActiveSession) { + recordCapabilityRefusal(ctx, capabilityElicitation) + } return nil, err } diff --git a/pkg/vmcp/server/sdk_sampling_adapter.go b/pkg/vmcp/server/sdk_sampling_adapter.go index e59020c4e6..c113a3bd8c 100644 --- a/pkg/vmcp/server/sdk_sampling_adapter.go +++ b/pkg/vmcp/server/sdk_sampling_adapter.go @@ -5,6 +5,7 @@ package server import ( "context" + "errors" "github.com/stacklok/toolhive-core/mcpcompat/mcp" "github.com/stacklok/toolhive-core/mcpcompat/server" @@ -73,6 +74,11 @@ func (a *sdkSamplingAdapter) RequestSampling( resp, err := a.mcpServer.RequestSampling(ctx, mcpReq) if err != nil { + // Mirror of sdkElicitationAdapter's refusal recording — see the + // comment there and modern_capability_refusal.go. + if errors.Is(err, server.ErrNoActiveSession) { + recordCapabilityRefusal(ctx, capabilitySampling) + } return nil, err } From cbdc5c15b59947d763fc9b0f138415a2f1d1be4d Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 27 Jul 2026 20:58:57 +0000 Subject: [PATCH 2/2] Reword re-declaring for codespell Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GvBj7nNWqDQ3rKYNeY6aam --- pkg/vmcp/server/modern_dispatch.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/vmcp/server/modern_dispatch.go b/pkg/vmcp/server/modern_dispatch.go index 52ad12dbc5..71075c2fa2 100644 --- a/pkg/vmcp/server/modern_dispatch.go +++ b/pkg/vmcp/server/modern_dispatch.go @@ -449,7 +449,7 @@ func writeModernListError(ctx context.Context, w http.ResponseWriter, id any, me // 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 re-declaring cannot help. The spec's answer for a declared +// 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 @@ -501,8 +501,8 @@ func writeModernCallFailure( // 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 re-declaring learns immediately that -// re-declaring will not help here (the declared case is served by +// 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{