Skip to content

Add deny-by-default visibility to vMCP aggregation - #6163

Open
jerm-dro wants to merge 4 commits into
mainfrom
vmcp-aggregation-default-visibility
Open

Add deny-by-default visibility to vMCP aggregation#6163
jerm-dro wants to merge 4 commits into
mainfrom
vmcp-aggregation-default-visibility

Conversation

@jerm-dro

Copy link
Copy Markdown
Contributor

Summary

  • aggregation.tools is 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 add excludeAll: true for 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.
  • Adds aggregation.defaultVisibility (allow | deny). Under deny, a backend absent from tools contributes no tools, so only listed backends are advertised. A backend that is listed is opted in by its entry — its own excludeAll/filter then decide which of its tools show, so an overrides-only entry keeps working.
  • Defaults to allow, which is exactly today's behavior, so existing YAML configs and already-deployed CRs are unaffected and there is no migration or backfill.
  • Like the sibling visibility settings, this controls advertising only. Every backend tool stays in the routing table, so composite tools can still call hidden tools.

Closes #6073

Type of change

  • New feature

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)

task test passes with zero failures. New coverage:

  • TestDefaultAggregator_AdvertisingFilterPreservesBackendID — added cases for unset / allow / deny, plus deny combined with a listed backend's filter, with no filter, and with excludeAll. 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 for deny / 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 stash and unrelated to this change: 4 lint findings (3 gosec, 1 staticcheck) in files this PR does not touch, and the cmd/thv-operator/test-integration/virtualmcp envtest suite failing on a missing local /usr/local/kubebuilder/bin/etcd (that suite is excluded from task test).

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

Purely additive: one new optional string field with +kubebuilder:default=allow and enum: [allow, deny]. Omitting it yields the current behavior. No zz_generated deepcopy drift, since the field is a value type.

Changes

File Change
pkg/vmcp/config/config.go DefaultVisibility field on AggregationConfig + DefaultVisibility type and allow/deny constants
pkg/vmcp/aggregator/default_aggregator.go denyUnlisted derived at construction; applied in shouldAdvertiseTool's no-config branch
pkg/vmcp/config/validator.go Enum validation for the CLI path (no admission webhook there)
cmd/thv-operator/pkg/vmcpconfig/converter.go Carry the field through CRD → config conversion
deploy/charts/operator-crds/** (2 files) Regenerated CRDs (both chart copies)
docs/operator/crd-api.md Regenerated
docs/operator/virtualmcpserver-api.md Field docs + deny-by-default example + scope note
docs/arch/10-virtual-mcp-architecture.md Expanded the Tool Filtering section
3 *_test.go Coverage described above

Does this introduce a user-facing change?

Yes. vMCP operators can set aggregation.defaultVisibility: deny so that only workloads explicitly listed in aggregation.tools have their tools advertised. Adding a workload to the group then no longer exposes it by default. The default remains allow, so nothing changes for existing deployments unless the field is set.

aggregation:
  conflictResolution: prefix
  defaultVisibility: deny
  tools:
    - workload: github
      filter: ["get_issue", "list_prs"]
    - workload: jira
      filter: ["get_issue"]

Special notes for reviewers

Deliberate semantics — "listed means allowed". Under deny, an entry with neither filter nor excludeAll advertises all of that workload's tools; deny governs 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 unless filter enumerates — is not what this implements. Locked in by test.

The converter is the sharp edge. convertAggregation hand-copies fields rather than deep-copying, so omitting DefaultVisibility there would let the CRD accept defaultVisibility: deny while the rendered config falls back to advertise-everything — a security-relevant setting that looks applied and isn't. TestConvert_DefaultVisibilityPreserved guards 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 aggregation subtree has no x-kubernetes-preserve-unknown-fields, so on a cluster where the operator is upgraded but the CRDs are not, defaultVisibility: deny is 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:

  1. defaultVisibility is 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.
  2. On the Modern (2026-07-28) dispatch path, tools/call resolves 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

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.
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Jul 31, 2026
@jerm-dro
jerm-dro requested a review from Copilot July 31, 2026 17:47
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.47%. Comparing base (2c623d5) to head (03e13c4).

Files with missing lines Patch % Lines
pkg/vmcp/aggregator/default_aggregator.go 69.23% 7 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6163      +/-   ##
==========================================
- Coverage   72.51%   72.47%   -0.04%     
==========================================
  Files         739      739              
  Lines       76719    76768      +49     
==========================================
+ Hits        55629    55641      +12     
- Misses      17107    17159      +52     
+ Partials     3983     3968      -15     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) on AggregationConfig, 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: convertAggregation comments 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 in cmd/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.

Comment on lines +735 to +739
// 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,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 31, 2026
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 5, 2026
@jerm-dro
jerm-dro requested a lite review from Copilot August 5, 2026 21:15
@jerm-dro
jerm-dro marked this pull request as ready for review August 5, 2026 21:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read through this carefully plus traced the call paths. The feature works and the converter comment about copy semantics is the best thing in the diff — that's exactly the trap this would otherwise have fallen into.

One thing I think is a genuine regression: with the priority strategy, an unlisted backend can still win a name conflict and then get filtered out, so neither version of the tool ends up advertised. Details inline. Everything else is naming/docs, and one of them is a copy-pasteable example that doesn't work.

The bigger "hidden tools are still callable by name" issue is pre-existing (excludeAllTools and filter behave the same on main), so I don't think it belongs in this PR — but deny makes it the default posture for everything not enumerated, and CallTool's doc comment already claims it returns ErrNotFound for unadvertised names, which it doesn't. Worth a follow-up against main before this becomes the recommended way to curate a group.

// tool; under "deny", withhold it so only backends named in the config
// contribute tools. A backend WITH a config is opted in either way — its
// own ExcludeAll/Filter decide below.
return !a.denyUnlisted

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filter runs after ResolveConflicts has already had every backend participate, which I think produces a surprising outcome with the priority strategy.

priorityOrder is independent of tools, so a backend can be in the priority order but have no tools entry. Then:

  1. Both backends expose search. The unlisted one is higher priority, so selectWinner picks it (priority_resolver.go:147) and the listed backend's search is dropped right there.
  2. Here, the winner has no entry in toolConfigMap, so denyUnlisted withholds it.

Net result: search disappears from tools/list entirely, even though a workload the operator explicitly listed was offering it. On main this didn't happen — the unlisted winner was advertised, so the tool was at least present.

Cheapest fix is probably validation rather than reordering the pipeline: reject (or warn on) a priorityOrder entry that has no tools entry when defaultVisibility: deny. Reordering means resolving conflicts twice, once over the visible candidate set and once over the complete set for composites, which feels like its own PR.

A mixed listed/unlisted collision test under priority would pin whichever behaviour you decide is right.

case "", DefaultVisibilityAllow, DefaultVisibilityDeny:
default:
return fmt.Errorf("defaultVisibility must be one of: allow, deny")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the priority-strategy comment on default_aggregator.go — this seems like the natural place for the guard, right after the enum check, since validateConflictStrategy above has already confirmed priorityOrder is well-formed.

Something like: when DefaultVisibility == DefaultVisibilityDeny, any PriorityOrder entry with no matching Tools[].Workload is a config error (or at minimum a warn), because that backend can win a conflict and then be withheld.

Separately, and much smaller: a typo in tools[].workload under deny silently withholds everything from the real backend, with no diagnostic anywhere in the path. Worth a slog.Warn at aggregator construction when a Tools entry matches no backend — under allow a typo just means "no filter applied", but under deny it means "this backend contributes nothing", which is a much worse thing to debug from an empty tool list.

Comment thread pkg/vmcp/config/config.go Outdated
// +kubebuilder:validation:Enum=allow;deny
// +kubebuilder:default=allow
// +optional
DefaultVisibility DefaultVisibility `json:"defaultVisibility,omitempty" yaml:"defaultVisibility,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about the name, while it's still free to change — after release this is a CRD field and we're stuck with it.

It only gates tools. mergeResources, mergeResourceTemplates and mergePrompts have no equivalent check, so under deny an unlisted backend's resources and prompts are still advertised. That's consistent with ExcludeAllTools — but that name says "Tools" and this one doesn't. The fact that the doc block has to disclaim the scope (and the operator guide does too) is the tell. defaultToolVisibility would carry its own scope.

"deny" is the most overloaded word in access control. The doc correctly says advertising-only and mentions the routing table, but stops short of the part that actually matters to whoever configures this: a hidden tool is still directly callable by name over the Modern path. CallTool routes off agg.RoutingTable, which keeps everything, and authorizeToolCall synthesizes a bare &vmcp.Tool{Name: name} rather than rejecting an unadvertised name — so with no Cedar policies configured (allow-all admission) a client that guesses jira_get_issue gets through. Legacy clients don't, because the SDK only has the advertised tools registered per session.

That asymmetry is pre-existing and not this PR's job to fix. But "Use this when the set of exposed tools must be enumerated deliberately" reads like an enforcement guarantee. One added sentence — hidden tools remain callable by name, use Cedar for enforcement — would set the right expectation.

Comment thread pkg/vmcp/config/config.go Outdated
// DefaultVisibility names the advertising default applied to backends with no
// per-workload Tools entry. The zero value is "" (unset), which behaves as
// DefaultVisibilityAllow so existing configs keep today's behavior.
type DefaultVisibility string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing // +gendoc, so the generated reference emits a link to #vmcpconfigdefaultvisibility with no matching section — see the two bare blank lines in the crd-api.md hunk, that's where the type stanza should have rendered. Every other named type in this file has the marker.

Suggested change
type DefaultVisibility string
// DefaultVisibilityAllow so existing configs keep today's behavior.
// +gendoc
type DefaultVisibility string

Needs a task crdref-gen re-run after.

Comment thread pkg/vmcp/config/config.go Outdated
// every other visibility setting here, this controls advertising only — hidden
// tools remain in the routing table for composite tools (see the type doc).
// +kubebuilder:validation:Enum=allow;deny
// +kubebuilder:default=allow

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but worth a thought: this default changes no behaviour — "" is already treated as allow both in NewDefaultAggregator and in the validator — while it does change bytes. apiextensions applies structural-schema defaults on decode, so after the CRD upgrade every existing VirtualMCPServer with spec.config.aggregation set reads back with defaultVisibility: "allow", which gets marshalled into config.yaml, which changes the ConfigMap content checksum, which is stamped on the pod template. So all vMCP deployments restart once on upgrade for a no-op field.

Dropping the marker avoids that with zero behaviour change. Keeping it is defensible (explicit in kubectl get -o yaml is nice) — just worth calling out in the user-facing-change section if so.

Comment thread docs/operator/virtualmcpserver-api.md Outdated
Comment on lines +263 to +275
spec:
groupRef:
name: my-services
aggregation:
conflictResolution: prefix
defaultVisibility: deny
tools:
# Only these two workloads are advertised. Any other workload in
# my-services contributes no tools, including ones added later.
- workload: github
filter: ["get_issue", "list_prs"]
- workload: jira
filter: ["get_issue"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong nesting — the CRD field is .spec.config.aggregation, not .spec.aggregation (VirtualMCPServerSpec.Config config.Config with json:"config,omitempty"). groupRef at the top level is right, which makes this easy to miss.

Depending on the cluster's field validation this is either rejected or silently pruned, and in the pruned case the operator ends up with allow-by-default while believing deny is on — the worst possible failure for this particular feature.

Suggested change
spec:
groupRef:
name: my-services
aggregation:
conflictResolution: prefix
defaultVisibility: deny
tools:
# Only these two workloads are advertised. Any other workload in
# my-services contributes no tools, including ones added later.
- workload: github
filter: ["get_issue", "list_prs"]
- workload: jira
filter: ["get_issue"]
spec:
groupRef:
name: my-services
config:
aggregation:
conflictResolution: prefix
defaultVisibility: deny
tools:
# Only these two workloads are advertised. Any other workload in
# my-services contributes no tools, including ones added later.
- workload: github
filter: ["get_issue", "list_prs"]
- workload: jira
filter: ["get_issue"]

Heads up that the adjacent prefix, priority and manual examples have the same mistake already — probably worth fixing all four in one go while you're in here.

Comment thread docs/operator/virtualmcpserver-api.md Outdated
> **Note**: `defaultVisibility` controls **advertising** only, as do
> `excludeAllTools` and `excludeAll`. Tools hidden this way remain in the routing
> table so composite tools can still call them, and `defaultVisibility` does not
> affect resources or prompts, which are advertised regardless. For per-identity

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This note is good and I'd keep it, but it stops one step short. "Tools hidden this way remain in the routing table so composite tools can still call them" describes why the tools are kept; it doesn't say that a direct tools/call naming one of them also succeeds (on the Modern path, with allow-all admission when no Cedar policies are configured).

Suggest making that explicit here, since this is the paragraph an operator will actually read before turning deny on.

})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this file's header says it exists to pin the BackendID-population contract, but this test's real subject is deny-visibility and routing-table retention. The test itself is good — asserting both "withheld from advertised" and "still in the routing table" is exactly right — it's just hard to find here. default_aggregator_test.go next to the existing ExcludeAllPreservesRoutingTableForCompositeTools cases would be a more natural home.

Rename defaultVisibility to defaultToolVisibility. The setting gates
tools only — an unlisted backend's resources, resource templates, and
prompts are still advertised under deny — so the name now carries its
own scope instead of relying on a doc disclaimer. Free to change before
the field ships in a release.

Reject defaultToolVisibility deny combined with the priority strategy
when a priorityOrder entry has no tools entry. Conflict resolution runs
before the advertising filter, so an unlisted backend could win a name
conflict and then be withheld, hiding the tool from every backend that
offered it. Validating up front is cheaper than resolving conflicts
twice, once over the visible set and once over the routable set.

Warn when a tools entry names no backend in the group. Under allow a
typo just means no filter is applied, but under deny the real backend
contributes nothing and an empty tools/list is the only symptom.

Drop the kubebuilder default. Empty already behaves as allow everywhere
that reads the field, so defaulting changed only the serialized bytes —
and apiextensions applies it on decode, which would rewrite config.yaml,
change the ConfigMap checksum, and restart every vMCP deployment once
for a no-op field.

Add the missing gendoc marker so the generated reference renders the
type section its field link points at, fix the aggregation examples to
nest under spec.config, and move the deny test next to the ExcludeAll
routing-table cases.
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/M Medium PR: 300-599 lines changed labels Aug 5, 2026
@jerm-dro

jerm-dro commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — the priority interaction is a real find and I've fixed it. Pushed in 03e13c463.

Priority regression. Went with validation as you suggested: defaultToolVisibility: deny + priority is now rejected when a priorityOrder entry has no tools entry, with the offending workload named in the error. Guard sits right after the enum check, where you pointed. Reordering the pipeline would mean resolving conflicts twice — once over the visible candidates, once over the full set composites route against — and that's its own PR.

You left reject-vs-warn open and I went with reject: under deny a warning is easy to miss and the failure mode is a tool silently advertised by nobody. Worth knowing that makes it stricter than today — a config setting deny + priority with an unlisted priorityOrder entry now fails validation rather than starting. Say the word if you'd rather it warn.

Three validator cases pin it, including allow + priority staying valid, since the unlisted winner is still advertised there and the combination is harmless. I didn't add the aggregator-level collision test you asked for — with the config rejected the scenario is now unreachable — but if you meant it to document the underlying pipeline behaviour regardless of the guard, happy to add it.

Naming. Renamed to defaultToolVisibility. You're right that the doc disclaimer was the tell — resources and prompts genuinely aren't gated, so the name should carry that.

Typo diagnostic. Added. A tools entry matching no backend in the group now warns at aggregation, with different wording per mode so deny names the actual consequence ("contributes no tools") rather than the harmless one.

Default marker. Dropped — hadn't thought through the decode-time defaulting chain to the ConfigMap checksum. Confirmed default: allow is gone from the regenerated CRD, so no one-time restart.

+gendoc. Fixed; the type section renders now and the field link resolves.

Docs. Fixed the .spec.config.aggregation nesting in all four examples, including the three that were already wrong. Added the priority constraint. Moved the deny test next to the ExcludeAll routing-table cases.

On hidden tools being callable — agreed it's pre-existing and out of scope here, and you traced it to the same place I did. #6216 fixes it: it rejects tools/call for tools absent from the advertised set on the Modern path, and corrects CallTool's doc comment, which as you noted promises an ErrNotFound it doesn't deliver.

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked everything from the last round. Six of eight fully addressed, and a couple went further than I asked — splitting the unmatched-workload selection out from the logging so it's testable without swapping the global logger is a nicer shape than the bare slog.Warn I suggested. Rename is clean too; grepping the old name across the branch turns up nothing, including both CRD copies and the generated reference.

The priority-strategy fix via validation rather than resolving conflicts twice is the right trade. Only nit: the ConflictResolutionConfig == nil early return in validateDenyVisibilityPriorityOrder can't be reached, since validateAggregation already errors on nil above it. Harmless.

Still open, and fine by me to leave: the docs say hidden tools stay in the routing table for composites, but not that a direct tools/call naming one still succeeds. That belongs with the follow-up that adds the guard in core.CallTool — worth doing before deny becomes the recommended way to curate a group, since CallTool's own doc comment already promises ErrNotFound for unadvertised names.

One cosmetic thing you may or may not care about: the kubebuilder-default rationale lives inside the field's doc comment, so it now renders verbatim in the public CRD reference — operators reading crd-api.md get the ConfigMap-checksum reasoning. Above the doc block, or in the commit message, would keep it out.

Also noting for later: the deny+priority rule is Go-side only, no CEL on the CRD, so on Kubernetes a bad combination is accepted at apply time and fails at vMCP startup instead. Consistent with how the rest of this config validates, just worth knowing that's what operators will see.

Approving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Default deny filter for group exposed by vMCP

3 participants