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
Add the public, identity-parameterized VMCP domain interface and the core Config
struct to the root pkg/vmcp package (new file pkg/vmcp/vmcp.go). This is the contract-only first step of the vMCP New/Serve split: it establishes the domain
boundary that New(cfg) -> VMCP (#5437) implements and that Serve and decorators
consume — with no implementation yet. Placing the interface in the root package,
alongside the existing Tool/Resource/Prompt/result types in types.go, lets it
reference those domain types directly without introducing an import cycle.
Context
This is the very first PR of Phase 1 of RFC THV-0076 (the vMCP core-interface refactor),
which extracts a small, identity-parameterized domain object from today's god-object server.New. The interface is the primary contract (architecture.md "API Contracts
→ VMCP interface", lines 84-138): every method takes an explicit *auth.Identity and
never reads identity from context (anti-pattern #1), and no mcp-go types cross the
boundary (anti-pattern #5). The method set is the non-session-scoped generalization of
the existing Caller interface (pkg/vmcp/session/types/session.go:28), which is the
prior art for the shape and the nil-identity/anonymous semantics. Per decision R2, meta
is retained on CallTool/GetPrompt (diverging from the RFC's illustrative
signature) to match Caller and preserve _meta forwarding through vmcp.BackendClient.CallTool (types.go:646; the BackendClient interface is declared
at types.go:642). Per R6, ResourceReadResult.Meta may be
nil due to an SDK handler limitation. See research.md (organizational standards: stdlib
testing, SPDX headers, go-style) and architecture.md "Core Principles" for the full
rationale.
VMCP interface declared in pkg/vmcp/vmcp.go with ListTools, CallTool, ListResources, ReadResource, ListPrompts, GetPrompt, LookupTool, LookupResource, LookupPrompt, and Close, with identity as an explicit *auth.Identity parameter on every data method.
meta (map[string]any) is retained on CallTool and GetPrompt (R2), with a
doc note on the _meta-forwarding rationale.
No mcp-go types appear anywhere in the interface signature (anti-pattern Implement secret injection #5);
the interface references only root-package domain types + *auth.Identity.
The interface doc comment encodes the full behavioral contract: identity explicit
and never read from context; nil identity = anonymous; decorators may only subtract
reachability; args/meta maps are read-only (copy-before-mutate); and ResourceReadResult.Meta may be nil per the SDK handler limitation (R6).
Core Config struct declared with its typed fields (collaborators, workflowDefs, the domain ElicitationRequester, Authz, cross-cutting TelemetryProvider/AuditConfig, and the injected health.StatusProvider) — field
declarations only, no wiring/construction logic (P1.4 New(cfg) -> VMCP core constructor #5437 fills the bodies). Typed (not
comment-only) so the first PR doesn't draw an empty-struct lint nit (C4).
Package compiles with no import cycle (task build green); SPDX header on the new .go file.
PR is ≤ 400 LOC and ≤ 10 files changed (excluding tests/docs/generated).
server.New signature and observable behavior unchanged.
All tests pass (task test); lint clean (task lint-fix).
Code reviewed and approved.
Technical Approach
Recommended Implementation
Create pkg/vmcp/vmcp.go in the rootpkg/vmcp package and declare the VMCP
interface exactly as specified in architecture.md (lines 105-127), generalizing the
existing Caller method set to be non-session-scoped. Add the core Config struct as a
field-only declaration documenting the collaborators it will hold (aggregator, router,
backend registry, backend client, composer, workflow defs, the Authz config for the
later admission seam, plus cross-cutting TelemetryProvider/AuditConfig) — but no
constructor body: New(cfg *Config) (VMCP, error) is defined and implemented in #5437, not here.
Declare the core Config with its typed fields (aggregator, router, backend registry,
backend client, the domain ElicitationRequester, workflow defs, the Authz config for
the later admission seam, the cross-cutting TelemetryProvider/AuditConfig, and the
injected health.StatusProvider) — field declarations only, no constructor body: New
is implemented in #5437. Typed fields (not a comment-only stub) mean #5437 fills in bodies rather than the struct shape, and the first PR doesn't draw an empty-struct lint
nit (C4).
This PR is the contract only. Per the task scope, declare the func New(cfg *Config) (VMCP, error) (and Serve) signatures as forward declarations only if needed to make
the package compile; otherwise omit them entirely and leave their bodies to #5437 / #5439. Prefer not to add stubs that the linter will flag as unused — keep the surface
to the interface, its doc contract, and the Config type. The full doc comment on the
interface is a first-class deliverable here: it is the durable encoding of the contract
that downstream PRs must honor.
Patterns & Frameworks
Domain-only boundary: no mcp-go imports in vmcp.go. The interface speaks only in
root-package domain types (Tool, Resource, Prompt, ToolCallResult, ResourceReadResult, PromptGetResult) and *auth.Identity (anti-pattern Implement secret injection #5).
Identity explicit, never from context on the public path (anti-pattern fix(typo): corrects readme #1) — ctx is for cancellation/trace only.
Decorator extension model: the doc contract states decorators hold only an inner VMCP and may only subtract reachability; they cannot widen access. Mirrors the
list-filter/call-deny pairing in pkg/authz/tool_filter.go.
Conventions: .claude/rules/go-style.md (SPDX header, doc comments, copy-before-
mutating-caller-input), .claude/rules/vmcp-anti-patterns.md. Per R8, tests in this
area use stdlib testing.T + testify — though this contract-only PR may need no new
tests beyond a compile check.
pkg/vmcp/types.go:367,406,424 — Tool, Resource, Prompt structs the interface
returns; Tool.BackendID (386), Resource.BackendID (420), Prompt.BackendID (435)
— logical ids safe to expose; populated by the advertising filter (pinned in P1.2 Pin BackendID through the advertising filter (tests) #5435).
pkg/vmcp/types.go:512,552,576 — ToolCallResult, ResourceReadResult, PromptGetResult returned by the call/read/get methods.
pkg/vmcp/types.go:556-561 — ResourceReadResult.Meta nil note (R6): the SDK
resources/read handler cannot forward _meta; the interface doc must document this.
pkg/vmcp/types.go:646 — vmcp.BackendClient.CallTool (the _meta-forwarding path
that motivates keeping meta on CallTool, R2); the BackendClient interface is
declared at types.go:642.
pkg/vmcp/session/types/session.go:28 — Caller interface (prior art). CallTool
with meta (41-48), ReadResource (58), GetPrompt (71), Close (80); the VMCP
method set generalizes this to be non-session-scoped.
pkg/vmcp/session/types/session.go:182,224,228 — ShouldAllowAnonymous, ErrUnauthorizedCaller, ErrNilCaller: the nil-identity/anonymous semantics the doc
contract references (the concrete behavior is reproduced by New in P1.4 New(cfg) -> VMCP core constructor #5437).
pkg/vmcp/server/server.go:301 — server.New (7-param signature); stays stable and
is not touched by this PR.
github.com/stacklok/toolhive/pkg/auth — auth.Identity (the explicit identity param;
redacts Token/UpstreamTokens, so it is never logged — Core Principle Implement secret injection #5).
Component Interfaces
The exact contract to declare (root pkg/vmcp package). meta is retained on CallTool/GetPrompt per R2. The doc comment shown is the load-bearing deliverable.
// VMCP is the core Virtual MCP domain object.//// Contract:// - Identity is an explicit parameter on every method and is NEVER read from// context (anti-pattern #1). A nil identity is anonymous; bound-identity// mismatch is the caller's concern only at the session layer (in Serve), not// here — the core takes an already-authenticated *auth.Identity.// - Implementations MUST be safe for concurrent use.// - Decorators may only SUBTRACT reachability: filter list output or refuse a// call before delegating to inner. They have no path to backends except// through inner, so they cannot widen access.// - args/meta maps are treated as read-only; the core copies before mutating.// - ReadResource results: Meta may be nil — the mcp-go resources/read handler// cannot forward _meta (see types.go:556-561). Do not rely on it (R6).typeVMCPinterface {
ListTools(ctx context.Context, identity*auth.Identity) ([]Tool, error)
CallTool(ctx context.Context, identity*auth.Identity, namestring,
argsmap[string]any, metamap[string]any) (*ToolCallResult, error)
ListResources(ctx context.Context, identity*auth.Identity) ([]Resource, error)
ReadResource(ctx context.Context, identity*auth.Identity, uristring) (*ResourceReadResult, error)
ListPrompts(ctx context.Context, identity*auth.Identity) ([]Prompt, error)
GetPrompt(ctx context.Context, identity*auth.Identity, namestring,
argsmap[string]any) (*PromptGetResult, error)
// Lookup* resolve an advertised name/URI to the capability (incl. BackendID)// WITHOUT invoking it. Returns an error for an unknown/unadvertised name —// the validation seam for the call path. Lookups apply the same admission// filter as List*, so they never resolve a denied capability.LookupTool(ctx context.Context, identity*auth.Identity, namestring) (*Tool, error)
LookupResource(ctx context.Context, identity*auth.Identity, uristring) (*Resource, error)
LookupPrompt(ctx context.Context, identity*auth.Identity, namestring) (*Prompt, error)
// Close releases core-held resources (backend connections, etc.). Idempotent.Close() error
}
// Config holds the collaborators New assembles into the core (typed fields declared// here; New's body lands in #5437). Cross-cutting TelemetryProvider/AuditConfig are// consumed by both New and Serve (not a clean partition, R3); HealthStatusProvider is// the read-only health view built at the composition root (A2; nil => no filtering).typeConfigstruct {
Aggregator aggregator.AggregatorRouter router.RouterBackendRegistry vmcp.BackendRegistryBackendClient vmcp.BackendClientWorkflowDefsmap[string]*composer.WorkflowDefinitionElicitationElicitationRequester// domain-typed (#5436)Authz*authz.Config// feeds the admission seam (#5438)TelemetryProvider*telemetry.Provider// cross-cutting (also on ServerConfig, R3)AuditConfig*audit.Config// cross-cutting (also on ServerConfig, R3)HealthStatusProvider health.StatusProvider// injected; built at the composition root (A2)// Exact field set is finalized by New (#5437) and deriveCoreConfig (#5444);// this PR declares the shape (typed fields), not the construction logic.
}
// Implemented in #5437; declared here only if required to compile.// func New(cfg *Config) (VMCP, error)
Testing Strategy
Unit Tests
Compile-time conformance is the primary guarantee for a contract-only PR
(task build / task test green). No behavior to exercise yet — New is unimplemented.
(Optional) A trivial compile-only assertion that a nil/placeholder value can be
typed as VMCP, if it adds clarity without introducing a dead stub.
Integration / Behavioral Parity Tests
None required for this PR — server.New is untouched and the core is not yet wired
into the live path; the parity suite remains green unchanged.
Edge Cases
Verify no import cycle is introduced between pkg/vmcp (root) and types.go/ pkg/auth (the reason the interface lives in the root package).
Confirm zero mcp-go imports in vmcp.go (grep guard) — enforces anti-pattern Implement secret injection #5 at
the boundary from day one.
Description
Add the public, identity-parameterized
VMCPdomain interface and the coreConfigstruct to the root
pkg/vmcppackage (new filepkg/vmcp/vmcp.go). This is thecontract-only first step of the vMCP New/Serve split: it establishes the domain
boundary that
New(cfg) -> VMCP(#5437) implements and thatServeand decoratorsconsume — with no implementation yet. Placing the interface in the root package,
alongside the existing
Tool/Resource/Prompt/result types intypes.go, lets itreference those domain types directly without introducing an import cycle.
Context
This is the very first PR of Phase 1 of RFC THV-0076 (the vMCP core-interface refactor),
which extracts a small, identity-parameterized domain object from today's god-object
server.New. The interface is the primary contract (architecture.md "API Contracts→ VMCP interface", lines 84-138): every method takes an explicit
*auth.Identityandnever reads identity from context (anti-pattern #1), and no mcp-go types cross the
boundary (anti-pattern #5). The method set is the non-session-scoped generalization of
the existing
Callerinterface (pkg/vmcp/session/types/session.go:28), which is theprior art for the shape and the nil-identity/anonymous semantics. Per decision R2,
metais retained on
CallTool/GetPrompt(diverging from the RFC's illustrativesignature) to match
Callerand preserve_metaforwarding throughvmcp.BackendClient.CallTool(types.go:646; theBackendClientinterface is declaredat
types.go:642). Per R6,ResourceReadResult.Metamay benil due to an SDK handler limitation. See research.md (organizational standards: stdlib
testing, SPDX headers, go-style) and architecture.md "Core Principles" for the full
rationale.
Parent Story: #5430
Dependencies: None (root; can start immediately)
Blocks: #5437
Acceptance Criteria
VMCPinterface declared inpkg/vmcp/vmcp.gowithListTools,CallTool,ListResources,ReadResource,ListPrompts,GetPrompt,LookupTool,LookupResource,LookupPrompt, andClose, with identity as an explicit*auth.Identityparameter on every data method.meta(map[string]any) is retained onCallToolandGetPrompt(R2), with adoc note on the
_meta-forwarding rationale.the interface references only root-package domain types +
*auth.Identity.and never read from context; nil identity = anonymous; decorators may only subtract
reachability;
args/metamaps are read-only (copy-before-mutate); andResourceReadResult.Metamay be nil per the SDK handler limitation (R6).Configstruct declared with its typed fields (collaborators,workflowDefs, the domainElicitationRequester,Authz, cross-cuttingTelemetryProvider/AuditConfig, and the injectedhealth.StatusProvider) — fielddeclarations only, no wiring/construction logic (P1.4 New(cfg) -> VMCP core constructor #5437 fills the bodies). Typed (not
comment-only) so the first PR doesn't draw an empty-struct lint nit (C4).
task buildgreen); SPDX header on the new.gofile.server.Newsignature and observable behavior unchanged.task test); lint clean (task lint-fix).Technical Approach
Recommended Implementation
Create
pkg/vmcp/vmcp.goin the rootpkg/vmcppackage and declare theVMCPinterface exactly as specified in architecture.md (lines 105-127), generalizing the
existing
Callermethod set to be non-session-scoped. Add the coreConfigstruct as afield-only declaration documenting the collaborators it will hold (aggregator, router,
backend registry, backend client, composer, workflow defs, the
Authzconfig for thelater admission seam, plus cross-cutting
TelemetryProvider/AuditConfig) — but noconstructor body:
New(cfg *Config) (VMCP, error)is defined and implemented in#5437, not here.
Declare the core
Configwith its typed fields (aggregator, router, backend registry,backend client, the domain
ElicitationRequester, workflow defs, theAuthzconfig forthe later admission seam, the cross-cutting
TelemetryProvider/AuditConfig, and theinjected
health.StatusProvider) — field declarations only, no constructor body:Newis implemented in #5437. Typed fields (not a comment-only stub) mean #5437 fills in
bodies rather than the struct shape, and the first PR doesn't draw an empty-struct lint
nit (C4).
This PR is the contract only. Per the task scope, declare the
func New(cfg *Config) (VMCP, error)(andServe) signatures as forward declarations only if needed to makethe package compile; otherwise omit them entirely and leave their bodies to #5437 /
#5439. Prefer not to add stubs that the linter will flag as unused — keep the surface
to the interface, its doc contract, and the
Configtype. The full doc comment on theinterface is a first-class deliverable here: it is the durable encoding of the contract
that downstream PRs must honor.
Patterns & Frameworks
vmcp.go. The interface speaks only inroot-package domain types (
Tool,Resource,Prompt,ToolCallResult,ResourceReadResult,PromptGetResult) and*auth.Identity(anti-pattern Implement secret injection #5).ctxis for cancellation/trace only.inner VMCPand may only subtract reachability; they cannot widen access. Mirrors thelist-filter/call-deny pairing in
pkg/authz/tool_filter.go..claude/rules/go-style.md(SPDX header, doc comments, copy-before-mutating-caller-input),
.claude/rules/vmcp-anti-patterns.md. Per R8, tests in thisarea use stdlib
testing.T+ testify — though this contract-only PR may need no newtests beyond a compile check.
Code Pointers
pkg/vmcp/vmcp.go(new) — theVMCPinterface + coreConfig. Authoritative shape:architecture.md "API Contracts → VMCP interface" (lines 89-134).
pkg/vmcp/types.go:367,406,424—Tool,Resource,Promptstructs the interfacereturns;
Tool.BackendID(386),Resource.BackendID(420),Prompt.BackendID(435)— logical ids safe to expose; populated by the advertising filter (pinned in P1.2 Pin BackendID through the advertising filter (tests) #5435).
pkg/vmcp/types.go:512,552,576—ToolCallResult,ResourceReadResult,PromptGetResultreturned by the call/read/get methods.pkg/vmcp/types.go:556-561—ResourceReadResult.Metanil note (R6): the SDKresources/read handler cannot forward
_meta; the interface doc must document this.pkg/vmcp/types.go:646—vmcp.BackendClient.CallTool(the_meta-forwarding paththat motivates keeping
metaonCallTool, R2); theBackendClientinterface isdeclared at
types.go:642.pkg/vmcp/session/types/session.go:28—Callerinterface (prior art).CallToolwith
meta(41-48),ReadResource(58),GetPrompt(71),Close(80); theVMCPmethod set generalizes this to be non-session-scoped.
pkg/vmcp/session/types/session.go:182,224,228—ShouldAllowAnonymous,ErrUnauthorizedCaller,ErrNilCaller: the nil-identity/anonymous semantics the doccontract references (the concrete behavior is reproduced by
Newin P1.4 New(cfg) -> VMCP core constructor #5437).pkg/vmcp/server/server.go:301—server.New(7-param signature); stays stable andis not touched by this PR.
github.com/stacklok/toolhive/pkg/auth—auth.Identity(the explicit identity param;redacts
Token/UpstreamTokens, so it is never logged — Core Principle Implement secret injection #5).Component Interfaces
The exact contract to declare (root
pkg/vmcppackage).metais retained onCallTool/GetPromptper R2. The doc comment shown is the load-bearing deliverable.Testing Strategy
Unit Tests
(
task build/task testgreen). No behavior to exercise yet —Newis unimplemented.nil/placeholder value can betyped as
VMCP, if it adds clarity without introducing a dead stub.Integration / Behavioral Parity Tests
server.Newis untouched and the core is not yet wiredinto the live path; the parity suite remains green unchanged.
Edge Cases
pkg/vmcp(root) andtypes.go/pkg/auth(the reason the interface lives in the root package).vmcp.go(grep guard) — enforces anti-pattern Implement secret injection #5 atthe boundary from day one.
Out of Scope
Newimplementation — the*coreVMCPconcrete type, collaborator wiring, healthfiltering, and method bodies land in P1.4 New(cfg) -> VMCP core constructor #5437.
Serve/ServerConfigimplementation — P2.1 Serve skeleton + ServerConfig #5439.Admissioninterface / Cedar wiring) — P1.5 Core admission seam (bounded rewrite) #5438.ElicitationRequester— P1.3 Domain-typed ElicitationRequester (bounded rewrite) #5436.server.New,cli/serve.go,vmcpconfig.Config, the CRD/YAML model, orany wire/storage format.
References
/Users/trey/Documents/GitHub/stacklok/toolhive-rfcs/rfcs/THV-0076-vmcp-core-interface.mdarchitecture.md"API Contracts → VMCP interface" (lines 84-138), "Core Principles"research.md(organizational standards, codebase patterns)