Add deny-by-default visibility to vMCP aggregation - #6163
Conversation
The aggregation.tools list is sparse and fails open: a backend in the group with no entry has every tool advertised. Operators scoping a vMCP to a curated subset must remember to add excludeAll for each new group member, and forgetting silently widens what clients see. Add aggregation.defaultVisibility. Under "deny" a backend absent from tools contributes no tools, so only listed backends are advertised. A listed backend is opted in by its entry; its own excludeAll/filter then decide which of its tools show. Defaults to "allow", the pre-existing behavior, so existing configs and deployed CRs are unaffected and no migration is needed. Like the sibling visibility settings this controls advertising only, leaving all tools in the routing table for composite tools.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6163 +/- ##
==========================================
- Coverage 72.59% 72.56% -0.04%
==========================================
Files 736 736
Lines 76359 76367 +8
==========================================
- Hits 55435 55415 -20
- Misses 16981 17026 +45
+ Partials 3943 3926 -17 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR adds a new aggregation.defaultVisibility setting to vMCP tool aggregation so operators can choose between the current “fail open” behavior (allow, default) and a “deny-by-default” advertising mode (deny) where only backends explicitly listed under aggregation.tools contribute advertised tools. The change is explicitly scoped to advertising only: hidden tools remain routable for composite tools, and prompts/resources are unaffected.
Changes:
- Introduce
DefaultVisibility(allow/deny) onAggregationConfig, validate it on the CLI config path, and carry it through CRD → rendered config conversion. - Teach the default aggregator to withhold tools from unlisted backends when
defaultVisibility: deny. - Add focused unit tests covering compatibility (unset/allow) and deny-by-default behavior, plus conversion preservation.
Key Concerns
- suggestion:
convertAggregationcomments contradict themselves about “deep copy” vs “hand-copy”. This is minor but worth fixing to avoid misleading future maintainers about aliasing/mutation expectations (see stored comment incmd/thv-operator/pkg/vmcpconfig/converter.go).
Testing Assessment
The added tests cover the important input matrix (unset vs allow vs deny, deny + listed backend with filter/no filter/excludeAll, mixed listed+unlisted backends) and also reassert the routing-table invariant, which is the key safety property for “advertising-only” changes.
vMCP Anti-Pattern Check
No new vMCP anti-patterns stood out in the touched pkg/vmcp/** files (notably: no new interface churn/abstractions (anti-pattern #8), no new shared mutable state or concurrency hazards, and no “god object” expansion concerns from this change).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/vmcp/config/config.go | Add DefaultVisibility type/field and document allow vs deny advertising semantics. |
| pkg/vmcp/config/validator.go | Validate defaultVisibility values for the CLI config path. |
| pkg/vmcp/config/validator_test.go | Add coverage for allow/deny/unset validity and invalid value rejection. |
| pkg/vmcp/aggregator/default_aggregator.go | Implement deny-by-default advertising for unlisted backends via a derived denyUnlisted flag. |
| pkg/vmcp/aggregator/advertised_backendid_test.go | Extend advertising tests for allow/deny/unset and add mixed-backend deny scenario. |
| cmd/thv-operator/pkg/vmcpconfig/converter.go | Preserve DefaultVisibility through CRD → rendered config conversion. |
| cmd/thv-operator/pkg/vmcpconfig/converter_test.go | Guard against converter regression dropping defaultVisibility. |
| deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml | Regenerated CRD schema/docs including defaultVisibility (chart template copy). |
| deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml | Regenerated CRD schema/docs including defaultVisibility (packaged CRD copy). |
| docs/operator/crd-api.md | Regenerated CRD API docs reflecting the new field and semantics. |
| docs/operator/virtualmcpserver-api.md | Add user-facing docs and deny-by-default example + advertising-only scope note. |
| docs/arch/10-virtual-mcp-architecture.md | Expand architecture docs around tool filtering/advertising semantics. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Carried through explicitly: this converter hand-copies fields rather than | ||
| // deep-copying, so a visibility setting omitted here would be accepted by the | ||
| // CRD and then silently dropped before the vMCP process ever sees it — | ||
| // failing OPEN on a setting users rely on to withhold tools. | ||
| DefaultVisibility: srcAgg.DefaultVisibility, |
There was a problem hiding this comment.
Good catch — and the contradiction ran deeper than the two comments disagreeing: the pre-existing "Start with a deep copy" claim was simply wrong, and had been before this PR.
This is a field-by-field copy that aliases the source's slices (ConflictResolutionConfig.PriorityOrder, WorkloadToolConfig.Filter); only Overrides is genuinely deep-copied (via DeepCopy() in resolveToolConfigRefs). So "deep copy" was misleading in exactly the way you'd worry about — a maintainer trusting it might mutate a slice here and silently mutate the CR's in-memory spec.
Replaced both comments with one accurate statement covering the two things that actually matter: treat the source's slices as read-only, and every new AggregationConfig field must be added to this literal explicitly or it gets silently dropped (which for a visibility setting means failing open). Kept the fail-open rationale as you asked.
Fixed in 9bf1406.
The comment claimed a deep copy while the code does a field-by-field copy that aliases slices, and the note added alongside DefaultVisibility contradicted it. State the actual semantics once, including the aliasing caveat and why each new field must be listed explicitly.
Summary
aggregation.toolsis a sparse list that fails open: a backend in the group with no entry has every one of its tools advertised. An operator scoping a vMCP to a curated subset has to remember to addexcludeAll: truefor each new group member, and forgetting silently widens what clients see. Reported in Default deny filter for group exposed by vMCP #6073, where the group grows over time and each addition is a chance to accidentally expose a server.aggregation.defaultVisibility(allow|deny). Underdeny, a backend absent fromtoolscontributes no tools, so only listed backends are advertised. A backend that is listed is opted in by its entry — its ownexcludeAll/filterthen decide which of its tools show, so an overrides-only entry keeps working.allow, which is exactly today's behavior, so existing YAML configs and already-deployed CRs are unaffected and there is no migration or backfill.Closes #6073
Type of change
Test plan
task test)task test-e2e)task lint-fix)task testpasses with zero failures. New coverage:TestDefaultAggregator_AdvertisingFilterPreservesBackendID— added cases for unset /allow/deny, plusdenycombined with a listed backend'sfilter, with no filter, and withexcludeAll. The existing table already asserts the routing-table invariant, so each new case also proves tools stay routable.TestDefaultAggregator_DefaultVisibilityDenyMixedBackends— the motivating scenario: a group with one listed and one unlisted backend. Asserts only the listed backend is advertised and the unlisted backend's tools remain in the routing table.TestConvert_DefaultVisibilityPreserved— CRD → rendered-config carry-through fordeny/allow/ unset.validateAggregation— accepts unset/allow/deny, rejects an unknown value.Two pre-existing failures in this workspace, confirmed identical on a clean tree via
git stashand unrelated to this change: 4 lint findings (3 gosec, 1 staticcheck) in files this PR does not touch, and thecmd/thv-operator/test-integration/virtualmcpenvtest suite failing on a missing local/usr/local/kubebuilder/bin/etcd(that suite is excluded fromtask test).API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Purely additive: one new optional string field with
+kubebuilder:default=allowandenum: [allow, deny]. Omitting it yields the current behavior. Nozz_generateddeepcopy drift, since the field is a value type.Changes
pkg/vmcp/config/config.goDefaultVisibilityfield onAggregationConfig+DefaultVisibilitytype andallow/denyconstantspkg/vmcp/aggregator/default_aggregator.godenyUnlistedderived at construction; applied inshouldAdvertiseTool's no-config branchpkg/vmcp/config/validator.gocmd/thv-operator/pkg/vmcpconfig/converter.godeploy/charts/operator-crds/**(2 files)docs/operator/crd-api.mddocs/operator/virtualmcpserver-api.mddocs/arch/10-virtual-mcp-architecture.md*_test.goDoes this introduce a user-facing change?
Yes. vMCP operators can set
aggregation.defaultVisibility: denyso that only workloads explicitly listed inaggregation.toolshave their tools advertised. Adding a workload to the group then no longer exposes it by default. The default remainsallow, so nothing changes for existing deployments unless the field is set.Special notes for reviewers
Deliberate semantics — "listed means allowed". Under
deny, an entry with neitherfilternorexcludeAlladvertises all of that workload's tools;denygoverns only backends with no entry at all. This matches the request in #6073 ("only showing servers that are specifically listed") and avoids silently blanking out existing curated setups (e.g. overrides-only entries) the moment someone enables the flag. The stricter alternative — deny unlessfilterenumerates — is not what this implements. Locked in by test.The converter is the sharp edge.
convertAggregationhand-copies fields rather than deep-copying, so omittingDefaultVisibilitythere would let the CRD acceptdefaultVisibility: denywhile the rendered config falls back to advertise-everything — a security-relevant setting that looks applied and isn't.TestConvert_DefaultVisibilityPreservedguards this; I verified it genuinely catches the regression by deleting the copy line and confirming the failure (expected "deny", got "") before restoring it. Worth keeping in mind for any future field added here.Upgrade ordering matters. The
aggregationsubtree has nox-kubernetes-preserve-unknown-fields, so on a cluster where the operator is upgraded but the CRDs are not,defaultVisibility: denyis pruned at admission and the setting silently fails open. Standard ToolHive CRD-before-operator ordering, but the failure mode here is "tools you expected hidden are advertised", so it may deserve a release note.Scope — what this does not do. Documented in both docs files:
defaultVisibilityis advertising-only, so it does not affect resources or prompts, which have no filtering config at all. A vMCP scoped this way still exposes every backend resource and prompt.tools/callresolves straight against the routing table, so a hidden tool is still callable by name when no Cedar policy is configured. That is a pre-existing bug independent of this PR and is tracked as a separate follow-up; the Legacy path is unaffected because only advertised tools are registered per session. Reviewers evaluating this as a hardening measure should know that fix is a prerequisite for calling it a boundary.Related but intentionally out of scope: #3493 (tool-level deny-list with default-allow) is a different question — per-tool convenience rather than per-server scoping — and points the opposite way on fail-open. Kept separate so the two defaults don't land in one confusing change.
Generated with Claude Code