Skip to content

P4.2 Runnable decorator example embedder #5447

@tgrunnagle

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

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions