P4.2 Runnable decorator example embedder
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
Description
Description
Add a runnable decorator example embedder that demonstrates the one supported
extension mechanism of the new vMCP core: decoration over the VMCP interface. The
example implements a subtract-only decorator that filters its ListTools output
and refuses the corresponding CallTool (via LookupTool) before delegating to
inner, proving the two invariants of architecture.md Core Principle #3 —
(a) list and call stay consistent, and (b) a decorator can only subtract
reachability, never widen it.
Context
This is the final task of the vMCP New/Serve split (RFC Phase 4). By the time it
lands, the identity-parameterized VMCP interface, New(cfg) -> VMCP, and
Serve(ctx, VMCP, *ServerConfig) -> *Server are merged (#5430/002/003), and
server.New is a stable thin wrapper. #5446 documented the decorator extension
model in docs/arch/vmcp-library.md / pkg/vmcp/doc.go and added a forward
reference to "the decorator example embedder" — this task supplies that example so
the documented mechanism is also executable and compiler-verified.
The example is the concrete realization of the RFC §API Changes "Decorator
example" (filteringVMCP) and architecture.md Core Principle #3 (decorators
subtract, never widen; lines ~355-357). It mirrors the filter/deny pattern in
pkg/authz/tool_filter.go
(filterToolsByPolicy drops disallowed tools from the advertised list;
authorizeToolCall denies the matching call), but operates over the domain VMCP
interface instead of mcp-go types — so no mcp-go crosses the example's boundary
(Core Principle #1).
This task changes no production behavior and adds no CLI flags.
Parent Story: #[#5433]
Dependencies: #[#5446] (the docs that name and forward-reference this example)
Blocks: None (leaf task)
Acceptance Criteria
- A runnable example embedder compiles and demonstrates a subtract-only
decorator overVMCP: the decorator holds onlyinner VMCP(plus a
consumer-defined policy) and has no path to backends except throughinner. - The example demonstrates list/call consistency: a tool the decorator
filters out ofListToolsis also denied byCallTool(the deny is decided via
LookupToolbefore delegating), so "what is advertised" cannot drift from "what
is callable". - The example demonstrates that a decorator cannot widen access: it can only
remove items theinnercore already advertised — it cannot surface a tool the
core did not return, nor make a call the core would refuse. -
ListTools/CallTool(and at least the resource/prompt list+lookup shape,
or a clear note that they follow the same pattern) are covered so the symmetry is
visible. - The example does not import mcp-go and does not reach below the
VMCP
interface (no routing/aggregator/registry internals) — extension is purely by
composition overVMCP(Core Principle #1, anti-pattern #5). - The example is exercised by the test suite (
task test) — e.g. a godoc
testable example with// Output:or a unit test asserting the filter+deny
behavior — so "runnable + compiles" is enforced in CI, not just by inspection. - PR is ≤ 400 LOC and ≤ 10 files changed (excluding tests/docs/generated)
-
server.Newsignature and observable behavior unchanged - No production behavior change; no CLI flags added (
task docsis a no-op,
generateddocs/cli/thv_vmcp_*.mdunchanged) - All tests pass (
task test); lint clean (task lint-fix) - Code reviewed and approved
Technical Approach
Recommended Implementation
Implement a small filteringVMCP decorator that embeds/holds only inner vmcp.VMCP
and a consumer-defined capability policy, then delegates every method to inner:
ListToolscallsinner.ListTools, then drops any tool the policy disallows
(keyed off the logical, safe-to-exposeTool.BackendID— never a backend
address; seeresearch.mdline 74 and Core Principle #4).CallToolresolves the name to a*vmcp.Toolviainner.LookupTool(which yields
theBackendID), applies the same policy decision, and either returns a
sentinel "not allowed" error or delegates toinner.CallTool. Because list and
call consult the same policy against the sameinneraggregation, they cannot
drift — this is exactly thetool_filter.gofilter-then-deny shape lifted to the
VMCPinterface.ListResources/ReadResourceandListPrompts/GetPromptfollow the identical
shape (filter the list, lookup-then-deny on access); implement them or document
that they mirror the tool path.- All other methods (
LookupResource,LookupPrompt,Close, etc.) pass straight
through toinner.
The "cannot widen" property is structural: the decorator never constructs tools
or contacts a backend — its only data source is inner's return values, so it can
only return a subset. The example should make this self-evident (a short comment +
a demonstration that requesting a filtered tool yields the deny error rather than a
backend call).
Example location — confirm against the live repo before implementing. Two
repo-consistent options (the existing examples/ directory at the repo root holds
only JSON/YAML config samples, not Go programs, and there are currently no
func Example* godoc examples or example_test.go files in the tree — so prefer
not to invent a new top-level convention):
- Preferred — godoc testable example in
pkg/vmcp(e.g.
pkg/vmcp/example_decorator_test.gowithfunc Example_filteringDecorator()and
an// Output:block, plus thefilteringVMCPtype in the same_testfile or
a smallpkg/vmcp/internal/example/package). It is compiled and run by
task test, lives next to theVMCPinterface it decorates, and is discoverable
from godoc — satisfying "runnable" with zero new tooling. - Alternative — a tiny embedder
mainundercmd/(matching the
cmd/vmcp/main.goembedder convention) that wiresNew→filteringVMCP→
Serve. This is closer to a real downstream embedder (e.g.stacklok/brood-box,
which embeds underinternal/infra/mcp/) but adds a buildable binary; only take
this route if "embedder" is meant literally and amainis desired.
Whichever location is chosen, ensure #5446's forward reference ("see the
decorator example embedder") resolves to it.
Patterns & Frameworks
- Decorator over the
VMCPinterface — composition only; holdinner vmcp.VMCP
and delegate. This is the supported extension seam (RFC;architecture.mdCore
Principle #3). Do not use thesession.Decorator/NewDecoratingFactory
seam — the RFC's Alternative 2 explicitly rejects it for the public API
(SDK-coupled, session-scoped;research.mdline 252). - Filter-then-deny — mirror
pkg/authz/tool_filter.go:ListToolsfilters,
CallToollooks up + denies with the same predicate. - No mcp-go across the
VMCPboundary (Core Principle #1, anti-pattern #5); keep
the example in domain types +*auth.Identityonly. Tool.BackendIDis a logical id, safe to expose to decorators
(Core Principle #4;security.md"Don't store internal addressing"). Filter on it,
never on a backend address/URL.- Copy before mutating caller input if the example rebuilds/edits any returned
slice orargs/metamap (.claude/rules/go-style.md). Prefer allocating a new
output slice (asfilterToolsByPolicydoes withmake([]T, 0, len)). - Conventions:
.claude/rules/go-style.md(SPDX headers, error handling),
.claude/rules/vmcp-anti-patterns.md,.claude/rules/security.md.
Code Pointers
pkg/vmcp/(root package) - home of theVMCPinterface (added in #5434) and
the domain result types; the example lives here (preferred option above) and
decoratesvmcp.VMCPusingvmcp.Tool/vmcp.ToolCallResultetc.pkg/vmcp/types.go-Tool.BackendID(line 386),Resource.BackendID(line 420),
Prompt.BackendID(line 435); the logical ids the decorator filters on.pkg/authz/tool_filter.go- reference implementation to mirror:
filterToolsByPolicy(filter the advertised list) andauthorizeToolCall(deny
the matching call). The example lifts this filter/deny pattern onto theVMCP
interface (domain types instead of mcp-go).pkg/vmcp/doc.go(158 lines) - package docs updated by #5446 that describe the
decorator extension model and forward-reference this example; keep the example
consistent with that text.docs/arch/vmcp-library.md- the embedding doc (#5446) whose decorator section
points to "the decorator example embedder"; ensure the link target matches the
chosen location.cmd/vmcp/main.go- the existing embeddermainconvention (alternative
location option 2).pkg/vmcp/server/server.go:301-server.New(7-param signature; stays stable,
untouched by this task).- RFC THV-0076 §API Changes "Decorator example" (
filteringVMCP, lines ~298-336) -
the canonical snippet this example realizes.
Component Interfaces
The example decorates the VMCP interface defined in #5434 (signatures per RFC
THV-0076 §API Changes, lines 248-272). Illustrative shape (final names/policy are at
the implementer's discretion; ErrCapabilityNotAllowed is an example-local sentinel,
not an existing domain error):
// CapabilityPolicy is a consumer-defined allow predicate keyed on the logical,
// safe-to-expose BackendID (never a backend address).
type CapabilityPolicy interface {
Allow(id *auth.Identity, backendID string) bool
}
// filteringVMCP is a subtract-only decorator: it holds ONLY the inner VMCP (plus a
// policy) and has no path to backends except through inner, so it can only remove
// reachability the inner core already grants — it cannot widen access.
type filteringVMCP struct {
inner vmcp.VMCP
policy CapabilityPolicy
}
func (f *filteringVMCP) ListTools(ctx context.Context, id *auth.Identity) ([]vmcp.Tool, error) {
tools, err := f.inner.ListTools(ctx, id)
if err != nil {
return nil, err
}
out := make([]vmcp.Tool, 0, len(tools))
for _, t := range tools {
if f.policy.Allow(id, t.BackendID) { // subtract only
out = append(out, t)
}
}
return out, nil
}
func (f *filteringVMCP) CallTool(
ctx context.Context, id *auth.Identity, name string, args map[string]any,
) (*vmcp.ToolCallResult, error) {
tool, err := f.inner.LookupTool(ctx, id, name) // same source of truth as ListTools
if err != nil {
return nil, err
}
if !f.policy.Allow(id, tool.BackendID) { // identical predicate => list/call stay consistent
return nil, fmt.Errorf("%w: %s", ErrCapabilityNotAllowed, name)
}
return f.inner.CallTool(ctx, id, name, args)
}
// ListResources/ReadResource, ListPrompts/GetPrompt follow the same shape;
// LookupResource/LookupPrompt/Close delegate straight to inner.
Testing Strategy
Unit Tests / Runnable Example (the example must be exercised by task test)
- List filtering: with a fake/mock
inner VMCPadvertising tools across two
BackendIDs and a policy allowing only one,filteringVMCP.ListToolsreturns only
the allowed subset. - List/call consistency: a tool absent from the filtered
ListToolsoutput is
denied byCallToolwith the sentinel error, andinner.CallToolis not
invoked for it. - Allowed pass-through: an allowed tool is returned by
ListToolsand its
CallTooldelegates toinnerand returns the inner result unchanged. - Cannot widen: a tool the
innercore does not advertise never appears in
the decorator'sListTools, and calling it is denied (the decorator has no way to
fabricate reachability). - If implemented as a godoc
Example_*, an// Output:block makes the
demonstrated behavior assertion-checked bygo test.
Integration / Behavioral Parity Tests
- (If the embedder-
mainoption is chosen) the example builds viatask build
andNew→filteringVMCP→Servewiring compiles against the real API; no
parity assertion needed since the decorator is example-only and not in the
server.Newpath.
Edge Cases
- nil-identity / anonymous: the policy is consulted with the identity as passed;
the decorator does not read identity from context (Core Principle #2). Document the
policy's behavior fornilidentity. - Empty inner list / inner error:
ListTools/CallToolpropagateinner's error
and an empty advertised set yields an empty filtered set (no panic ontools[:0]
style reuse — prefer a freshly allocated slice).
Out of Scope
- Any change to
VMCP,New,Serve,ServerConfig, orserver.New— shipped in
#5430/002/003; this task only consumes them in an example. - The docs that describe and forward-reference the decorator model — that is #5446
(P4.1). This task only ensures the referenced example exists and matches the docs. - Adding the decorator (or its policy) to the live
thv vmcp serve/server.New
path — the example is illustrative only. - A new transport-layer extension mechanism beyond decoration over
VMCP(RFC
non-goal).
References
- RFC THV-0076 (link above) — §API Changes "Decorator example" (
filteringVMCP),
"Decorators cannot widen access" (lines ~525-526), Alternative 2 (why not
session.Decorator). architecture.md— Core Principle #3 "Decorators subtract, never widen"
(lines ~355-357); Core Principles #1 (no mcp-go across the boundary), #4
(BackendIDis a logical id); "Phase 4 — Docs + example" P4.2 (lines 434-438).pkg/authz/tool_filter.go— filter/deny reference pattern.- Parent story: #[#5433]
- Epic: #5419
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the VMCP interface and types in pkg/vmcp/, then compare pkg/authz/tool_filter.go with RFC THV-0076's decorator example. Confirm the example location and the interfaces before implementing the filter-and-deny behavior for tools, resources, and prompts. Run the focused example or unit tests, then task test and task lint-fix; done means the example compiles, exercises list/call consistency, and the docs reference resolves.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend-api-design
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100