Skip to content
22 changes: 17 additions & 5 deletions pkg/authz/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,23 @@ var MCPMethodToFeatureOperation = map[string]featureOperation{
"features/list": {Feature: "", Operation: authorizers.MCPOperationList}, // Capability discovery
"roots/list": {Feature: "", Operation: ""}, // Root directory discovery

// server/discover is intentionally NOT allow-listed: it default-denies (403) for now.
// Its response enumerates tool/resource descriptors and would bypass
// ResponseFilteringWriter (which only filters tools/list, prompts/list, resources/list,
// and find_tool). When Modern serving is wired up (#5830), add it as allow +
// response-filter, not always-allowed.
// server/discover, Modern's (2026-07-28) replacement for initialize+capability
// negotiation, is always-allowed on THIS path (the single-server pkg/runner HTTP
// authz Middleware -- vMCP's Modern dispatcher never consults this map at all, it
// re-homes admission through core.Check*/core.List* directly). The always-allowed
// choice rests on initialize parity, not on any per-request filtering this map
// enforces: DiscoverResult carries the exact same Capabilities *ServerCapabilities
// (+ Instructions) shape InitializeResult does, and "initialize" above has always
// been always-allowed in this map. discover therefore adds no new exposure class --
// note ServerCapabilities.Experimental/.Extensions (arbitrary backend-authored maps)
// and Instructions (free text) are already freeform fields a backend can populate on
// the always-allowed initialize response today, so "no descriptors" is a property of
// how vMCP's dispatcher happens to build the value, not a guarantee this wire shape
// makes on its own. Classifying it as MCPOperationList instead would be safe too --
// response_filter.go hardcodes an exact 4-method filter list (tools/list,
// prompts/list, resources/list, find_tool), so server/discover would just pass
// through unfiltered -- but always-allowed is simpler and equally safe here.
"server/discover": {Feature: "", Operation: ""},

// Subscriptions - always allowed for now. This method carries no single resource
// identifier the parser extracts (params are a notification-type filter with an
Expand Down
21 changes: 9 additions & 12 deletions pkg/authz/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,15 +331,15 @@ func TestMiddleware(t *testing.T) {
expectAuthorized: false,
},
{
name: "Server discover default-denies (not allow-listed)",
name: "Server discover is always allowed",
method: "server/discover",
params: map[string]interface{}{},
claims: jwt.MapClaims{
"sub": "user123",
"name": "John Doe",
},
expectStatus: http.StatusForbidden,
expectAuthorized: false,
expectStatus: http.StatusOK,
expectAuthorized: true,
},
{
name: "Subscriptions listen is always allowed",
Expand Down Expand Up @@ -467,16 +467,13 @@ func TestSubscriptionsListenIsAllowlistedPendingDelivery(t *testing.T) {
require.Equal(t, featureOperation{}, MCPMethodToFeatureOperation["subscriptions/listen"])
}

// TestServerDiscoverIsNotAllowlisted guards a deliberate omission: server/discover must
// stay absent from MCPMethodToFeatureOperation so it default-denies (403) until Modern
// serving is wired up with proper response filtering (#5830). Its response enumerates
// tool/resource descriptors, and re-adding it as always-allowed would let a Cedar-restricted
// client bypass ResponseFilteringWriter and enumerate the full catalog. This test forces a
// conscious decision if someone re-adds the entry.
func TestServerDiscoverIsNotAllowlisted(t *testing.T) {
// TestServerDiscoverIsAllowlisted guards the now-safe allow-listing of server/discover:
// its Modern envelope is post-admission capability flags (booleans), never per-resource
// descriptors, so unlike tools/list or prompts/list there is nothing here for
// ResponseFilteringWriter to filter -- always-allowed is correct, not a bypass.
func TestServerDiscoverIsAllowlisted(t *testing.T) {
t.Parallel()
_, ok := MCPMethodToFeatureOperation["server/discover"]
require.False(t, ok, "server/discover must not be allow-listed until Modern serving with response filtering lands (#5830)")
require.Equal(t, featureOperation{}, MCPMethodToFeatureOperation["server/discover"])
}

// TestMiddlewareWithGETRequest tests that the middleware doesn't panic with GET requests.
Expand Down
23 changes: 23 additions & 0 deletions pkg/vmcp/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net"
"os"
"path/filepath"
"strconv"
"time"

"go.opentelemetry.io/otel/trace"
Expand Down Expand Up @@ -51,6 +52,12 @@ import (
vmcpstatus "github.com/stacklok/toolhive/pkg/vmcp/status"
)

// modernDispatchEnvVar is the kill-switch env var for direct-to-core dispatch
// of well-formed MCP 2026-07-28 ("Modern") stateless requests (default off;
// see server.Config.ModernDispatchEnabled). Env-only ahead of any CLI-flag or
// CRD wiring.
const modernDispatchEnvVar = "TOOLHIVE_VMCP_MODERN_STATELESS"

// ServeConfig holds all parameters needed to start the vMCP server.
// Populated by the caller from Cobra flag values or equivalent.
// At least one of ConfigPath or GroupRef must be non-empty; ConfigPath takes
Expand Down Expand Up @@ -408,6 +415,21 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
}()
}

// Read the Modern-stateless-dispatch kill-switch once, here at the
// composition root. Unset is the deliberate "off" default. A non-empty
// value that fails to parse as a bool is an operator typo (a misspelled
// "true"), not a request to enable the feature — warn and stay disabled
// rather than silently treating it as false.
modernDispatchEnabled := false
if raw := os.Getenv(modernDispatchEnvVar); raw != "" {
var err error
if modernDispatchEnabled, err = strconv.ParseBool(raw); err != nil {
slog.Warn(fmt.Sprintf("%s has an unrecognized value %q; Modern stateless dispatch stays disabled",
modernDispatchEnvVar, raw))
modernDispatchEnabled = false
}
}

// Resolve transport defaults once here at the composition root: the
// vMCP config edge is the single place flags/CRD/YAML become a fully-resolved
// Config, so server.New, Serve, and the derive* helpers downstream are pure
Expand All @@ -420,6 +442,7 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
Host: cfg.Host,
Port: cfg.Port,
SessionTTL: cfg.SessionTTL,
ModernDispatchEnabled: modernDispatchEnabled,
AuthMiddleware: authMiddleware,
AuthzMiddleware: authzMiddleware,
AuthInfoHandler: authInfoHandler,
Expand Down
25 changes: 25 additions & 0 deletions pkg/vmcp/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ import (
"github.com/stacklok/toolhive/pkg/vmcp/router"
)

// DiscoverCapabilities summarizes, for one identity, whether each capability
// kind has at least one admission-filtered entry. It carries no descriptor
// arrays -- only presence flags -- unlike the []vmcp.Tool/Resource/etc. slices
// ListTools/ListResources/ListResourceTemplates/ListPrompts return.
type DiscoverCapabilities struct {
HasTools bool
HasResources bool
HasResourceTemplates bool
HasPrompts bool
}

// VMCP is the core Virtual MCP domain object.
//
// Contract:
Expand Down Expand Up @@ -197,6 +208,20 @@ type VMCP interface {
// corresponding authorized list would not show.
LookupBackend(ctx context.Context, identity *auth.Identity, backendID string) (*vmcp.Backend, error)

// Discover returns identity's capability-presence flags -- whether it is
// admitted to at least one tool, resource, resource template, and prompt --
// from a SINGLE aggregation of backend capabilities, for server/discover.
//
// It applies the exact same admission-filtered code paths
// ListTools/ListResources/ListResourceTemplates/ListPrompts use against one
// shared aggregated view, so a flag is true iff the ADMISSION-FILTERED set
// for that capability is non-empty -- never derived from the raw aggregate,
// which would leak capabilities identity cannot reach. This mirrors the
// post-admission-summary precedent ListBackends(filterUnauthorized=true)
// established, applied to presence flags instead of a backend list. See
// ListTools for the nil/anonymous identity semantics.
Discover(ctx context.Context, identity *auth.Identity) (DiscoverCapabilities, error)

// BackendHealth returns the backend health reporter the core owns, or nil when health
// monitoring is disabled. The core builds, starts, and (via Close) stops the monitor and
// filters capabilities with it; the transport layer uses this only to report on or sync
Expand Down
21 changes: 18 additions & 3 deletions pkg/vmcp/core/core_calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,12 @@ func (c *coreVMCP) CallTool(
}
return nil, fmt.Errorf("routing tool %q: %w", name, err)
}
return c.backendClient.CallTool(ctx, target, name, argsCopy, metaCopy)
result, err := c.backendClient.CallTool(ctx, target, name, argsCopy, metaCopy)
if err != nil {
return nil, err
}
result.BackendID = target.WorkloadID
return result, nil
}

// ReadResource reads the resource at uri from its backend. Returns
Expand Down Expand Up @@ -95,7 +100,12 @@ func (c *coreVMCP) ReadResource(
}
// Pass the advertised URI; the backend client owns the single translation to
// the backend's capability name (client.go:874), matching CallTool.
return c.backendClient.ReadResource(ctx, target, uri)
result, err := c.backendClient.ReadResource(ctx, target, uri)
if err != nil {
return nil, err
}
result.BackendID = target.WorkloadID
return result, nil
}

// GetPrompt retrieves the named prompt from its backend. args is treated as
Expand Down Expand Up @@ -126,7 +136,12 @@ func (c *coreVMCP) GetPrompt(
}
// Pass the advertised name; the backend client owns the single translation to
// the backend's capability name (client.go:927), matching CallTool.
return c.backendClient.GetPrompt(ctx, target, name, maps.Clone(args))
result, err := c.backendClient.GetPrompt(ctx, target, name, maps.Clone(args))
if err != nil {
return nil, err
}
result.BackendID = target.WorkloadID
return result, nil
}

// Complete resolves argument-completion candidates for the referenced prompt or
Expand Down
4 changes: 4 additions & 0 deletions pkg/vmcp/core/core_calls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func TestCallTool_RoutesToBackend(t *testing.T) {
got, err := c.CallTool(context.Background(), nil, "tool_a", map[string]any{"a": 1}, nil)
require.NoError(t, err)
assert.Equal(t, want, got)
assert.Equal(t, testBackendID, got.BackendID, "CallTool must stamp the routed target's backend onto the result")
}

func TestCallTool_NotFound(t *testing.T) {
Expand Down Expand Up @@ -121,6 +122,7 @@ func TestCallTool_CompositeWorkflow(t *testing.T) {
require.NotNil(t, got)
assert.False(t, got.IsError)
assert.Equal(t, true, got.StructuredContent["ok"])
assert.Empty(t, got.BackendID, "a composite tool has no single serving backend")
}

func TestCallTool_CompositeNotAccessible(t *testing.T) {
Expand Down Expand Up @@ -160,6 +162,7 @@ func TestReadResource(t *testing.T) {
got, err := c.ReadResource(context.Background(), nil, "file://a")
require.NoError(t, err)
assert.Equal(t, want, got)
assert.Equal(t, testBackendID, got.BackendID, "ReadResource must stamp the routed target's backend onto the result")
}

func TestReadResource_NotFound(t *testing.T) {
Expand Down Expand Up @@ -194,6 +197,7 @@ func TestGetPrompt(t *testing.T) {
got, err := c.GetPrompt(context.Background(), nil, "p1", map[string]any{"x": 1})
require.NoError(t, err)
assert.Equal(t, want, got)
assert.Equal(t, testBackendID, got.BackendID, "GetPrompt must stamp the routed target's backend onto the result")
}

func TestGetPrompt_CopyBeforeMutate(t *testing.T) {
Expand Down
39 changes: 39 additions & 0 deletions pkg/vmcp/core/core_vmcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,45 @@ func (c *coreVMCP) ListPrompts(ctx context.Context, identity *auth.Identity) ([]
return c.admission.FilterPrompts(ctx, identity, agg.Prompts)
}

// Discover aggregates backend capabilities ONCE and derives all four
// capability-presence flags from that single view, applying the exact same
// admission-filter code paths ListTools/ListResources/ListResourceTemplates/
// ListPrompts each apply independently (advertisedTools+FilterTools,
// FilterResources, filterResourceTemplates, FilterPrompts). Sharing those
// helpers against one aggregatedView call -- rather than reimplementing the
// filtering -- is what guarantees a flag can never drift from what the
// corresponding List* verb would show for the same identity.
func (c *coreVMCP) Discover(ctx context.Context, identity *auth.Identity) (DiscoverCapabilities, error) {
agg, err := c.aggregatedView(ctx)
if err != nil {
return DiscoverCapabilities{}, err
}

tools, err := c.admission.FilterTools(ctx, identity, c.advertisedTools(agg))
if err != nil {
return DiscoverCapabilities{}, err
}
resources, err := c.admission.FilterResources(ctx, identity, agg.Resources)
if err != nil {
return DiscoverCapabilities{}, err
}
templates, err := c.filterResourceTemplates(ctx, identity, agg.ResourceTemplates)
if err != nil {
return DiscoverCapabilities{}, err
}
prompts, err := c.admission.FilterPrompts(ctx, identity, agg.Prompts)
if err != nil {
return DiscoverCapabilities{}, err
}

return DiscoverCapabilities{
HasTools: len(tools) > 0,
HasResources: len(resources) > 0,
HasResourceTemplates: len(templates) > 0,
HasPrompts: len(prompts) > 0,
}, nil
}

// LookupTool resolves an advertised tool name (incl. composite tools) to its
// capability without invoking it. It delegates to ListTools, so it applies the
// same health/advertising AND admission view: a name that is unknown, unadvertised,
Expand Down
70 changes: 70 additions & 0 deletions pkg/vmcp/core/core_vmcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,76 @@ func TestListResourceTemplates_Empty(t *testing.T) {
assert.Empty(t, templates)
}

// TestDiscover_SingleAggregation verifies Discover derives all four
// capability-presence flags from ONE aggregation call (reg.List +
// AggregateCapabilities each Times(1)) rather than the four independent
// fan-outs ListTools/ListResources/ListResourceTemplates/ListPrompts would
// cost if called separately.
func TestDiscover_SingleAggregation(t *testing.T) {
t.Parallel()
cfg, m := baseConfig(t)

backends := []vmcp.Backend{{ID: testBackendID, HealthStatus: vmcp.BackendHealthy}}
m.reg.EXPECT().List(gomock.Any()).Return(backends).Times(1)
m.agg.EXPECT().AggregateCapabilities(gomock.Any(), backends).Return(&aggregator.AggregatedCapabilities{
Tools: []vmcp.Tool{backendTool("echo")},
Resources: []vmcp.Resource{{URI: "file://a", BackendID: testBackendID}},
ResourceTemplates: []vmcp.ResourceTemplate{{URITemplate: "file:///logs/{date}.txt", BackendID: testBackendID}},
Prompts: []vmcp.Prompt{{Name: "p1", BackendID: testBackendID}},
RoutingTable: &vmcp.RoutingTable{},
}, nil).Times(1)

c, err := New(cfg)
require.NoError(t, err)
t.Cleanup(func() { _ = c.Close() })

caps, err := c.Discover(context.Background(), nil)
require.NoError(t, err)
assert.Equal(t, DiscoverCapabilities{
HasTools: true, HasResources: true, HasResourceTemplates: true, HasPrompts: true,
}, caps)
}

// TestDiscover_EmptyAggregate verifies an empty aggregated view yields every
// flag false, not an error.
func TestDiscover_EmptyAggregate(t *testing.T) {
t.Parallel()
cfg, m := baseConfig(t)

m.reg.EXPECT().List(gomock.Any()).Return(nil)
m.agg.EXPECT().AggregateCapabilities(gomock.Any(), gomock.Any()).Return(&aggregator.AggregatedCapabilities{}, nil)

c, err := New(cfg)
require.NoError(t, err)
t.Cleanup(func() { _ = c.Close() })

caps, err := c.Discover(context.Background(), nil)
require.NoError(t, err)
assert.Equal(t, DiscoverCapabilities{}, caps)
}

// TestDiscover_DeniedIdentityHidesTools is the security-critical case: a
// backend advertising a tool must NOT set HasTools for an identity the
// admission seam denies that tool to. Deriving the flag from the raw
// aggregate instead of the admission-filtered set would leak the tool's
// existence to an identity that cannot call it.
func TestDiscover_DeniedIdentityHidesTools(t *testing.T) {
t.Parallel()
_, m := baseConfig(t)

authorizer := &mockAuthorizer{results: map[string]mockResult{"echo": {authorized: false}}}
c := checkCore(m, newCedarAdmission(authorizer))
expectAggregationAnyTimes(m, &aggregator.AggregatedCapabilities{
Tools: []vmcp.Tool{backendTool("echo")},
RoutingTable: &vmcp.RoutingTable{},
})

caps, err := c.Discover(t.Context(), cedarIdentity())
require.NoError(t, err)
assert.False(t, caps.HasTools,
"a denied identity must not see HasTools=true even though the backend advertises a tool")
}

func TestListTools_AggregationError(t *testing.T) {
t.Parallel()
cfg, m := baseConfig(t)
Expand Down
Loading
Loading