You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
vMCP's assembly — turning configuration into a running server — lives in pkg/vmcp/cli/serve.go (~700 lines). It builds backend discovery (the dynamic BackendRegistry + Kubernetes watcher, or static/inline backends), the aggregator,
router, backend client, health monitor, optimizer, session-manager config,
incoming-auth and rate-limit middleware, the workflow definitions, and finally the
server.
That wiring is not packaged. An embedder (e.g. the enterprise gateway) that wants
a vMCP server must re-implement the same sequence by hand and honor assembly rules
that today exist only in doc comments — and it breaks whenever OSS changes how vMCP
assembles itself. The cost of keeping that copy in sync belongs in the OSS.
#5541 (merged via #5542) encapsulated one seam — the Kubernetes-backed BackendRegistry + watcher — so embedders no longer hand-wire it. This issue finishes
the job for the assembly as a whole: a caller hands over config and gets back a VMCP core (to decorate) plus the transport config to serve. The OSS owns the
assembly; embedders compose vMCP instead of reimplementing it.
vMCP should encapsulate its own assembly. An embedder should hand over config and
get a server back — not wire up vMCP's internals or honor assembly rules that live
only in doc comments. […] which backends exist, and how they're discovered, is a
config choice — not a dependency the embedder constructs and injects.
This extends the direction of epic #5419 / RFC THV-0076. As written, THV-0076 keeps
assembly at the composition root and treats discovery as an injected dependency, so
this adds new public surface beyond the RFC — flagging for @jerm-dro and a possible RFC
follow-up before/with implementation.
Goal
Config in, server out — with decoration preserved (the RFC's supported extension
mechanism):
// Shared stateful collaborators (registry+watcher, telemetry, health) are built once// and passed to BOTH derivations via opts; every other field each derives from vmcpCfg.opts:= []app.Option{app.WithVersion(v) /*, app.WithBackendRegistry(reg, watcher), ... */}
core, coreCleanup, err:=app.BuildCore(ctx, vmcpCfg, opts...)
defercoreCleanup()
// the transport config is derived from vmcpCfg directly — NOT returned by BuildCoreserverCfg, srvCleanup, err:=app.BuildServerConfig(ctx, vmcpCfg, opts...)
defersrvCleanup()
// decoration is the embedder's business, invisible to the serversrv, err:=server.Serve(ctx, userScoped(core), serverCfg)
Dependency injection via core.New(&core.Config{...}) + server.Serve(ctx, v, &server.ServerConfig{...})stays for tests and advanced embedders — both coexist.
Design (proposed)
New OSS package — NOT root pkg/vmcp (it would import-cycle with core/server).
Proposed pkg/vmcp/app (name TBD).
Input is the existing serialized vmcpconfig.Config — already the single source
of truth (Group, Backends, OutgoingAuth.Source: inline|discovered, aggregation,
optimizer, incoming/outgoing auth, telemetry, audit, rate limiting, composite tools). No new parallel config type (avoids the go-style "parallel types that drift"
anti-pattern). The assembly OWNS interpreting it — including choosing the discovery
mechanism from OutgoingAuth.Source + Backends/Group — so the embedder no longer
encodes those rules.
BuildCore derives core.Config from vmcpConfig (+ opts) and calls core.New,
returning only the VMCP (for decoration). It does not return a ServerConfig — the transport config is not a byproduct of core construction.
BuildServerConfig derives *server.ServerConfig from vmcpConfig (+ opts) directly — an independent projection of the same input.
Both reuse the existing deriveCoreConfig / deriveServerConfig / WithDefaults
helpers (pkg/vmcp/server/derive.go, promoted to exported or relocated), so there is exactly one config-split path, shared with P3.2 Reduce server.New body to the wrapper #5445's reduced server.New.
Shared stateful collaborators are built once and threaded into both derivations,
never built twice: the TelemetryProvider (else duplicate OTEL pipelines), the *health.Monitor (else double health-check traffic / a divergent view), and — in
dynamic mode — the BackendRegistry+watcher (else two informer caches). These are the
override seams (Options): the caller (or the one-shot app.Serve) builds them once,
passes the same opts to both calls, and owns their lifecycle/cleanup. Each Build*
cleanup releases only what it acquired. This mirrors how deriveCoreConfig/ deriveServerConfig already take backendRegistry/healthMon as explicit shared args.
app.BuildServerConfig(ctx, *vmcpconfig.Config, ...Option) (*server.ServerConfig, cleanup func(), error)
— derives the transport config directly from vmcpConfig (+ opts), reusing the P3.1 deriveCoreConfig/deriveServerConfig config split #5444 derive helpers.
Functional Options for the runtime injectables and the shared stateful
collaborators (REST config, container factory, embedded auth server, telemetry
provider, health monitor, backend registry+watcher, version, host/port, logger) —
so the same opts passed to both Build* calls share one instance.
Unit tests: each derivation yields a working core / ServerConfig for both discovered and inline discovery; options are applied; a shared collaborator
passed to both is built once; each cleanup releases only what it acquired.
(optional) one-shot convenience app.Serve(ctx, *vmcpconfig.Config, ...Option) (*server.Server, error)
that builds the shared collaborators once, derives both configs, and serves — for
the no-decoration case.
Migrate pkg/vmcp/cli/serve.go to call the new assembly API (app.BuildCore + app.BuildServerConfig), replacing the hand-wired assembly in the OSS production
serve path with a call through the new package. This is what actually removes the
OSS duplication and proves the API is sufficient for the real caller. Coordinate
with P3.2 Reduce server.New body to the wrapper #5445 to ensure a single shared config-split path.
Out of scope (tracked as follow-ups)
Enterprise gateway adopting the API — separate repo / issue.
Retiring the legacy server.Config god-object and the deriveCoreConfig / deriveServerConfig helpers once both server.New and cli.Serve route through the
assembly.
Any change to the serialized vmcpconfig.Config schema / CRD / YAML.
A new programmatic vmcp.Config sum-type (e.g. Discovery: KubernetesDiscovery | StaticDiscovery): considered and rejected for now in favor of reusing vmcpconfig.Config, to avoid maintaining a second config type.
Summary / Motivation
vMCP's assembly — turning configuration into a running server — lives in
pkg/vmcp/cli/serve.go(~700 lines). It builds backend discovery (the dynamicBackendRegistry+ Kubernetes watcher, or static/inline backends), the aggregator,router, backend client, health monitor, optimizer, session-manager config,
incoming-auth and rate-limit middleware, the workflow definitions, and finally the
server.
That wiring is not packaged. An embedder (e.g. the enterprise gateway) that wants
a vMCP server must re-implement the same sequence by hand and honor assembly rules
that today exist only in doc comments — and it breaks whenever OSS changes how vMCP
assembles itself. The cost of keeping that copy in sync belongs in the OSS.
#5541 (merged via #5542) encapsulated one seam — the Kubernetes-backed
BackendRegistry+ watcher — so embedders no longer hand-wire it. This issue finishesthe job for the assembly as a whole: a caller hands over config and gets back a
VMCPcore (to decorate) plus the transport config to serve. The OSS owns theassembly; embedders compose vMCP instead of reimplementing it.
Raised as a blocker on #5542:
#5542 (comment)
This extends the direction of epic #5419 / RFC THV-0076. As written, THV-0076 keeps
assembly at the composition root and treats discovery as an injected dependency, so
this adds new public surface beyond the RFC — flagging for @jerm-dro and a possible RFC
follow-up before/with implementation.
Goal
Config in, server out — with decoration preserved (the RFC's supported extension
mechanism):
Dependency injection via
core.New(&core.Config{...})+server.Serve(ctx, v, &server.ServerConfig{...})stays for tests and advanced embedders — both coexist.Design (proposed)
pkg/vmcp(it would import-cycle withcore/server).Proposed
pkg/vmcp/app(name TBD).vmcpconfig.Config— already the single sourceof truth (
Group,Backends,OutgoingAuth.Source: inline|discovered, aggregation,optimizer, incoming/outgoing auth, telemetry, audit, rate limiting, composite tools).
No new parallel config type (avoids the go-style "parallel types that drift"
anti-pattern). The assembly OWNS interpreting it — including choosing the discovery
mechanism from
OutgoingAuth.Source+Backends/Group— so the embedder no longerencodes those rules.
Options carry the non-serialized runtime injectables that are CLI / composition-root concerns today: REST config (default in-cluster, reusing Public constructor for a Kubernetes-backed dynamic BackendRegistry (no pkg/vmcp/k8s import for embedders) #5541's
backendregistry.WithRESTConfig), container factory (TEI optimizer), embedded authserver, version string, host/port, and an injected
*slog.Logger(cf. vmcp: accept injected *slog.Logger via Config (avoid sharing slog.Default with the embedder) #5381).vmcpconfig.Config, mirroring the existingderiveCoreConfig/deriveServerConfigsplit (P3.1 deriveCoreConfig/deriveServerConfig config split #5444):BuildCorederivescore.ConfigfromvmcpConfig(+ opts) and callscore.New,returning only the
VMCP(for decoration). It does not return aServerConfig— the transport config is not a byproduct of core construction.BuildServerConfigderives*server.ServerConfigfromvmcpConfig(+ opts)directly — an independent projection of the same input.
Both reuse the existing
deriveCoreConfig/deriveServerConfig/WithDefaultshelpers (
pkg/vmcp/server/derive.go, promoted to exported or relocated), so there isexactly one config-split path, shared with P3.2 Reduce server.New body to the wrapper #5445's reduced
server.New.never built twice: the
TelemetryProvider(else duplicate OTEL pipelines), the*health.Monitor(else double health-check traffic / a divergent view), and — indynamic mode — the
BackendRegistry+watcher (else two informer caches). These are theoverride seams (
Options): the caller (or the one-shotapp.Serve) builds them once,passes the same opts to both calls, and owns their lifecycle/cleanup. Each
Build*cleanup releases only what it acquired. This mirrors how
deriveCoreConfig/deriveServerConfigalready takebackendRegistry/healthMonas explicit shared args.Backends.Scope
app.BuildCore(ctx, *vmcpconfig.Config, ...Option) (core.VMCP, cleanup func(), error)— derives
core.ConfigfromvmcpConfig(+ opts) and callscore.New. Returnsonly the core (no
ServerConfig). Reuses the P3.1 deriveCoreConfig/deriveServerConfig config split #5444 derive helpers and Public constructor for a Kubernetes-backed dynamic BackendRegistry (no pkg/vmcp/k8s import for embedders) #5541'sregistry constructor.
app.BuildServerConfig(ctx, *vmcpconfig.Config, ...Option) (*server.ServerConfig, cleanup func(), error)— derives the transport config directly from
vmcpConfig(+ opts), reusing theP3.1 deriveCoreConfig/deriveServerConfig config split #5444 derive helpers.
Options for the runtime injectables and the shared statefulcollaborators (REST config, container factory, embedded auth server, telemetry
provider, health monitor, backend registry+watcher, version, host/port, logger) —
so the same opts passed to both
Build*calls share one instance.BuildCore+BuildServerConfig→ decorate →server.Serve— extending the decorator example from P4.2 Runnable decorator example embedder #5447.ServerConfigfor bothdiscoveredandinlinediscovery; options are applied; a shared collaboratorpassed to both is built once; each
cleanupreleases only what it acquired.app.Serve(ctx, *vmcpconfig.Config, ...Option) (*server.Server, error)that builds the shared collaborators once, derives both configs, and serves — for
the no-decoration case.
pkg/vmcp/cli/serve.goto call the new assembly API (app.BuildCore+app.BuildServerConfig), replacing the hand-wired assembly in the OSS productionserve path with a call through the new package. This is what actually removes the
OSS duplication and proves the API is sufficient for the real caller. Coordinate
with P3.2 Reduce server.New body to the wrapper #5445 to ensure a single shared config-split path.
Out of scope (tracked as follow-ups)
server.Configgod-object and thederiveCoreConfig/deriveServerConfighelpers once bothserver.Newandcli.Serveroute through theassembly.
vmcpconfig.Configschema / CRD / YAML.vmcp.Configsum-type (e.g.Discovery: KubernetesDiscovery | StaticDiscovery): considered and rejected for now in favor of reusingvmcpconfig.Config, to avoid maintaining a second config type.Dependencies / coordination
server.Newto a wrapper over the samederive helpers): both must share a single config-split path.
References