Skip to content

P4.2 Runnable decorator example embedder #5447

Description

@tgrunnagle

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 over VMCP: the decorator holds only inner VMCP (plus a
    consumer-defined policy) and has no path to backends except through inner.
  • The example demonstrates list/call consistency: a tool the decorator
    filters out of ListTools is also denied by CallTool (the deny is decided via
    LookupTool before 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 the inner core 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 over VMCP (Core Principle fix(typo): corrects readme #1, anti-pattern Implement secret injection #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.New signature and observable behavior unchanged
  • No production behavior change; no CLI flags added (task docs is a no-op,
    generated docs/cli/thv_vmcp_*.md unchanged)
  • 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:

  • ListTools calls inner.ListTools, then drops any tool the policy disallows
    (keyed off the logical, safe-to-expose Tool.BackendID — never a backend
    address; see research.md line 74 and Core Principle Implement secret store #4).
  • CallTool resolves the name to a *vmcp.Tool via inner.LookupTool (which yields
    the BackendID), applies the same policy decision, and either returns a
    sentinel "not allowed" error or delegates to inner.CallTool. Because list and
    call consult the same policy against the same inner aggregation, they cannot
    drift — this is exactly the tool_filter.go filter-then-deny shape lifted to the
    VMCP interface.
  • ListResources/ReadResource and ListPrompts/GetPrompt follow 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 to inner.

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):

  1. Preferred — godoc testable example in pkg/vmcp (e.g.
    pkg/vmcp/example_decorator_test.go with func Example_filteringDecorator() and
    an // Output: block, plus the filteringVMCP type in the same _test file or
    a small pkg/vmcp/internal/example/ package). It is compiled and run by
    task test, lives next to the VMCP interface it decorates, and is discoverable
    from godoc — satisfying "runnable" with zero new tooling.
  2. Alternative — a tiny embedder main under cmd/ (matching the
    cmd/vmcp/main.go embedder convention) that wires NewfilteringVMCP
    Serve. This is closer to a real downstream embedder (e.g. stacklok/brood-box,
    which embeds under internal/infra/mcp/) but adds a buildable binary; only take
    this route if "embedder" is meant literally and a main is desired.

Whichever location is chosen, ensure #5446's forward reference ("see the
decorator example embedder") resolves to it.

Patterns & Frameworks

  • Decorator over the VMCP interface — composition only; hold inner vmcp.VMCP
    and delegate. This is the supported extension seam (RFC; architecture.md Core
    Principle Bump golangci/golangci-lint-action from 2f856675483cb8b9378ee77ee0beb67955aca9d7 to 4696ba8babb6127d732c3c6dde519db15edab9ea #3). Do not use the session.Decorator / NewDecoratingFactory
    seam — the RFC's Alternative 2 explicitly rejects it for the public API
    (SDK-coupled, session-scoped; research.md line 252).
  • Filter-then-deny — mirror pkg/authz/tool_filter.go: ListTools filters,
    CallTool looks up + denies with the same predicate.
  • No mcp-go across the VMCP boundary (Core Principle fix(typo): corrects readme #1, anti-pattern Implement secret injection #5); keep
    the example in domain types + *auth.Identity only.
  • Tool.BackendID is a logical id, safe to expose to decorators
    (Core Principle Implement secret store #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 or args/meta map (.claude/rules/go-style.md). Prefer allocating a new
    output slice (as filterToolsByPolicy does with make([]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 the VMCP interface (added in P1.1 Define VMCP interface + core Config #5434) and
    the domain result types; the example lives here (preferred option above) and
    decorates vmcp.VMCP using vmcp.Tool / vmcp.ToolCallResult etc.
  • 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) and authorizeToolCall (deny
    the matching call). The example lifts this filter/deny pattern onto the VMCP
    interface (domain types instead of mcp-go).
  • pkg/vmcp/doc.go (158 lines) - package docs updated by P4.1 Update vMCP architecture docs + doc.go #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 (P4.1 Update vMCP architecture docs + doc.go #5446) whose decorator section
    points to "the decorator example embedder"; ensure the link target matches the
    chosen location.
  • cmd/vmcp/main.go - the existing embedder main convention (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 VMCP advertising tools across two
    BackendIDs and a policy allowing only one, filteringVMCP.ListTools returns only
    the allowed subset.
  • List/call consistency: a tool absent from the filtered ListTools output is
    denied by CallTool with the sentinel error, and inner.CallTool is not
    invoked for it.
  • Allowed pass-through: an allowed tool is returned by ListTools and its
    CallTool delegates to inner and returns the inner result unchanged.
  • Cannot widen: a tool the inner core does not advertise never appears in
    the decorator's ListTools, 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 by go test.

Integration / Behavioral Parity Tests

  • (If the embedder-main option is chosen) the example builds via task build
    and NewfilteringVMCPServe wiring compiles against the real API; no
    parity assertion needed since the decorator is example-only and not in the
    server.New path.

Edge Cases

  • nil-identity / anonymous: the policy is consulted with the identity as passed;
    the decorator does not read identity from context (Core Principle Do we want the container monitor? #2). Document the
    policy's behavior for nil identity.
  • Empty inner list / inner error: ListTools/CallTool propagate inner's error
    and an empty advertised set yields an empty filtered set (no panic on tools[:0]
    style reuse — prefer a freshly allocated slice).

Out of Scope

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorvmcpVirtual MCP Server related issues

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions