Skip to content

vMCP: Implement lazy per-user capability discovery (MVP) #2502

@jhrozek

Description

@jhrozek

vMCP: Implement lazy per-user capability discovery (MVP)

Problem

The vMCP server discovers backend MCP server capabilities during startup in cmd/vmcp/app/commands.go:290. This calls aggregateCapabilities() which queries each backend's tools, resources, and prompts before any user has connected.

This breaks for backends requiring user-specific authentication. Token exchange strategies need the actual user's token to authorize capability queries. At startup there is no user context, so these backends reject the discovery requests.

Solution

Move capability discovery from startup to request time. Create a DiscoveryManager component that performs aggregation when a user makes their first request. At that point the user has authenticated and their identity is available via auth.IdentityFromContext().

This MVP implementation performs discovery on every request without caching. Caching will be added in a follow-up issue.

Architecture

Create a new pkg/vmcp/discovery/ package with three components:

DiscoveryManager (manager.go): Wraps the existing Aggregator and performs discovery with user context.

type Manager interface {
    Discover(ctx context.Context, backends []workloads.Reference) (*aggregator.AggregatedCapabilities, error)
}

type DefaultManager struct {
    aggregator aggregator.Aggregator
}

func (m *DefaultManager) Discover(ctx context.Context, backends []workloads.Reference) (*aggregator.AggregatedCapabilities, error) {
    return m.aggregator.AggregateCapabilities(ctx, backends)
}

Context helpers (context.go): Store and retrieve discovered capabilities in request context.

func WithDiscoveredCapabilities(ctx context.Context, caps *aggregator.AggregatedCapabilities) context.Context
func DiscoveredCapabilitiesFromContext(ctx context.Context) (*aggregator.AggregatedCapabilities, bool)

Middleware (middleware.go): HTTP middleware that performs discovery per request and attaches capabilities to context.

func Middleware(manager Manager, backends []workloads.Reference, logger *slog.Logger) func(http.Handler) http.Handler

The middleware extracts user identity from context (already set by auth middleware), calls manager.Discover(), and attaches the result to context for handlers to use.

Integration Changes

cmd/vmcp/app/commands.go - Remove startup aggregation, create DiscoveryManager:

// Remove this:
caps, err := aggregateCapabilities(cmd.Context(), backendRefs, agg)

// Add this:
discoveryManager := discovery.NewManager(agg)
srv, err := vmcp.NewServer(config, backends, discoveryManager, logger)

pkg/vmcp/server/server.go - Accept DiscoveryManager instead of capabilities:

type Server struct {
    // Remove: preAggregatedCaps
    // Add:
    discoveryMgr discovery.Manager
}

func NewServer(config *config.VirtualMCPServerConfig, backends []workloads.Reference, discoveryMgr discovery.Manager, logger *slog.Logger) (*Server, error)

In Start() method, add discovery middleware after auth middleware:

handler = discovery.Middleware(s.discoveryMgr, s.backends, s.logger)(handler)

Update MCP handlers to get capabilities from context:

caps, ok := discovery.DiscoveredCapabilitiesFromContext(r.Context())
if !ok {
    // return error
}

pkg/vmcp/router/router.go - If Router stores capabilities at construction, refactor to accept them per-request. Change method signatures from:

func (r *Router) ListTools(ctx context.Context) ([]types.Tool, error)

to:

func (r *Router) ListTools(ctx context.Context, caps *aggregator.AggregatedCapabilities) ([]types.Tool, error)

Implementation Notes

The existing aggregator.Aggregator already extracts user identity from context and passes it to the backend client. The auth RoundTripper in the backend client applies the appropriate auth strategy (including token exchange). No changes needed to the aggregation pipeline itself.

Middleware ordering matters: Auth → Discovery → Handlers. Discovery must run after auth middleware sets user identity in context.

If discovery fails, return HTTP 503 (Service Unavailable) rather than 500, signaling temporary backend unavailability.

Metadata

Metadata

Assignees

Labels

apiItems related to the APIauthenticationenhancementNew feature or requestgoPull requests that update go code

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions