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
12 changes: 12 additions & 0 deletions docs/arch/10-virtual-mcp-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,18 @@ non-nil `ListChangedSink` is supplied at connection time, the connector:
than a bare string so a typo is a compile error — and logs
`notifications/message` received out-of-call (log-only; no relay).

This propagation mechanism only applies to a Legacy (2025-11-25) backend: the
per-session factory (`pkg/vmcp/session/factory.go`) skips the persistent
connection — and therefore the handshake, the sink registration, and the
standalone GET stream above — for any backend whose cached revision is known
Modern (2026-07-28); see [Backend MCP Revision Classification](#backend-mcp-revision-classification)
above for how that revision is resolved and cached. This is correct, not a gap in the skip: Modern removed
`initialize` and `Mcp-Session-Id`, so there is no Legacy-shaped persistent
connection to hold and no standalone GET stream a Modern backend could push
on. Modern's own server-push mechanism is `subscriptions/listen` (see
[Transport Architecture](03-transport-architecture.md)), which vMCP does not
implement yet — so `list_changed` propagation is currently Legacy-only.

The sink is built once per session, at registration (`pkg/vmcp/server`'s
`buildListChangedSink`), closing over the SDK `ClientSession`, the session ID,
and the caller's identity **and per-request forwarded headers** captured **at
Expand Down
2 changes: 2 additions & 0 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion pkg/vmcp/cli/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,11 @@ func Serve(ctx context.Context, cfg ServeConfig) error {
}
// The factory never aggregates — the core is the single source of capability
// aggregation (agg feeds it via Config.Aggregator below).
sessionFactory := vmcpsession.NewSessionFactory(outgoingRegistry)
var sessionFactoryOpts []vmcpsession.MultiSessionFactoryOption
if revisions, ok := backendClient.(vmcp.RevisionReporter); ok {
sessionFactoryOpts = append(sessionFactoryOpts, vmcpsession.WithRevisionLookup(revisions.CachedRevision))
}
sessionFactory := vmcpsession.NewSessionFactory(outgoingRegistry, sessionFactoryOpts...)

// When the optimizer is enabled, its meta-tools are pass-through tools.
// Authz uses this for optimizer-aware authorization/filtering.
Expand Down
12 changes: 10 additions & 2 deletions pkg/vmcp/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -990,10 +990,18 @@ func (h *httpBackendClient) setRevision(workloadID string, rev mcpparser.Revisio
h.revisions.Store(workloadID, rev)
}

// Compile-time guard that this client still satisfies the optional read-model
// every consumer type-asserts to. Without it, renaming or dropping
// CachedRevision below would compile cleanly and silently disable the telemetry
// revision label, the /status field, and the session layer's Modern-connect
// skip — each of which degrades gracefully by design when the assertion fails.
var _ vmcp.RevisionReporter = (*httpBackendClient)(nil)

// CachedRevision reports a backend's resolved MCP revision for status/telemetry
// read-models. The second return is false when the backend has never been probed.
// Exported (not on vmcp.BackendClient) so the telemetry decorator and health
// monitor can read it via an optional interface without an interface/mock change.
// Exported (not on vmcp.BackendClient) so the telemetry decorator, health
// monitor and session factory can read it via vmcp.RevisionReporter without an
// interface/mock change.
func (h *httpBackendClient) CachedRevision(workloadID string) (mcpparser.Revision, bool) {
return h.cachedRevision(workloadID)
}
Expand Down
15 changes: 3 additions & 12 deletions pkg/vmcp/health/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,10 @@ import (

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

mcpparser "github.com/stacklok/toolhive/pkg/mcp"
"github.com/stacklok/toolhive/pkg/vmcp"
healthcontext "github.com/stacklok/toolhive/pkg/vmcp/health/context"
)

// revisionReporter is the optional accessor for a backend's cached MCP revision.
// The backend client (directly or through the telemetry decorator) implements it;
// it is NOT part of vmcp.BackendClient, so the monitor reaches it via a type
// assertion and simply reports no revision when absent.
type revisionReporter interface {
CachedRevision(workloadID string) (mcpparser.Revision, bool)
}

// WithHealthCheckMarker marks a context as a health check request.
// Authentication layers can use IsHealthCheck to identify and skip authentication
// for health check requests.
Expand Down Expand Up @@ -129,8 +120,8 @@ type Monitor struct {
checker vmcp.HealthChecker

// revisions reads each backend's negotiated MCP revision for the status
// read-model. Nil when the client does not implement revisionReporter.
revisions revisionReporter
// read-model. Nil when the client does not implement vmcp.RevisionReporter.
revisions vmcp.RevisionReporter

// statusTracker tracks health status for all backends.
statusTracker *statusTracker
Expand Down Expand Up @@ -267,7 +258,7 @@ func NewMonitor(

// The client (directly or via the telemetry decorator) optionally reports the
// negotiated MCP revision for the status read-model; nil when unsupported.
revisions, _ := client.(revisionReporter)
revisions, _ := client.(vmcp.RevisionReporter)

return &Monitor{
checker: checker,
Expand Down
12 changes: 2 additions & 10 deletions pkg/vmcp/internal/backendtelemetry/backendtelemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,6 @@ import (
"github.com/stacklok/toolhive/pkg/vmcp"
)

// revisionReporter is the optional accessor the concrete backend client exposes
// for its cached MCP revision (see client.CachedRevision). It is NOT part of
// vmcp.BackendClient, so it is reached via a type assertion — a client that does
// not implement it simply reports no revision.
type revisionReporter interface {
CachedRevision(workloadID string) (mcpparser.Revision, bool)
}

const (
instrumentationName = "github.com/stacklok/toolhive/pkg/vmcp"
)
Expand Down Expand Up @@ -141,12 +133,12 @@ type telemetryBackendClient struct {

var _ vmcp.BackendClient = telemetryBackendClient{}

// CachedRevision forwards to the wrapped client's optional revisionReporter so
// CachedRevision forwards to the wrapped client's optional vmcp.RevisionReporter so
// callers reaching the client THROUGH this decorator (e.g. the health monitor)
// can still read the negotiated revision. Returns (0, false) when the wrapped
// client does not report revisions.
func (t telemetryBackendClient) CachedRevision(workloadID string) (mcpparser.Revision, bool) {
if r, ok := t.backendClient.(revisionReporter); ok {
if r, ok := t.backendClient.(vmcp.RevisionReporter); ok {
return r.CachedRevision(workloadID)
}
return 0, false
Expand Down
40 changes: 40 additions & 0 deletions pkg/vmcp/mocks/mock_backend_client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions pkg/vmcp/server/authz_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ func buildCedarAuthzServer(
Authz: authzCfg,
AuditConfig: auditCfg,
CodeModeConfig: codeModeCfg,
// Required for TestIntegration_CedarAuthzDenial_ModernPath_IsAudited to
// exercise what it claims: dispatchModern's re-homed call gate. Without
// it the kill switch refuses the Modern request in classifyingHandler and
// dispatchModern never runs. (Before the classifier learned to refuse an
// unserved revision, the request instead fell through to the SDK path, so
// that test passed on a denial from a different gate entirely.) Safe for
// the Legacy-shaped tests sharing this helper: classifyingHandler passes
// Legacy traffic through regardless of this flag.
ModernDispatchEnabled: true,
},
router.NewSessionRouter(&vmcp.RoutingTable{}),
backendClient,
Expand Down
Loading
Loading