Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions cmd/thv-operator/pkg/vmcpconfig/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -727,11 +727,20 @@ func (c *Converter) convertAggregation(
ctx context.Context,
vmcp *mcpv1beta1.VirtualMCPServer,
) (*vmcpconfig.AggregationConfig, error) {
// Start with a deep copy of the source config
// Field-by-field copy, NOT a deep copy: scalars are copied by value, slices
// (ConflictResolutionConfig.PriorityOrder, WorkloadToolConfig.Filter) are shared
// with the source, and only Overrides is deep-copied. Two consequences:
// - Treat the source's slices as read-only; mutating them here would mutate the
// CR's in-memory spec.
// - Every new AggregationConfig field must be added HERE explicitly. A field
// omitted from this literal is accepted by the CRD and then silently dropped
// before the vMCP process ever sees it — which for a visibility setting means
// failing OPEN on config users rely on to withhold tools.
srcAgg := vmcp.Spec.Config.Aggregation
agg := &vmcpconfig.AggregationConfig{
ConflictResolution: srcAgg.ConflictResolution,
ExcludeAllTools: srcAgg.ExcludeAllTools,
ConflictResolution: srcAgg.ConflictResolution,
ExcludeAllTools: srcAgg.ExcludeAllTools,
DefaultToolVisibility: srcAgg.DefaultToolVisibility,
}

// Apply defaults for conflict resolution
Expand Down
50 changes: 50 additions & 0 deletions cmd/thv-operator/pkg/vmcpconfig/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,56 @@ func TestConvert_MCPToolConfigFailClosed(t *testing.T) {
}
}

// TestConvert_DefaultToolVisibilityPreserved guards against silently dropping the
// aggregation visibility setting. convertAggregation hand-copies fields rather
// than deep-copying, so omitting DefaultToolVisibility there would let the CRD accept
// `defaultToolVisibility: deny` while the rendered vMCP config falls back to
// advertise-everything — a security-relevant setting that looks applied and is
// not. An empty value must survive as empty (the aggregator treats it as allow),
// so pre-existing CRs keep today's behavior.
func TestConvert_DefaultToolVisibilityPreserved(t *testing.T) {
t.Parallel()

tests := []struct {
name string
set vmcpconfig.DefaultToolVisibility
want vmcpconfig.DefaultToolVisibility
}{
{name: "deny is carried through to the rendered config", set: vmcpconfig.DefaultToolVisibilityDeny, want: vmcpconfig.DefaultToolVisibilityDeny},
{name: "allow is carried through to the rendered config", set: vmcpconfig.DefaultToolVisibilityAllow, want: vmcpconfig.DefaultToolVisibilityAllow},
{name: "unset stays unset (treated as allow downstream)", set: "", want: ""},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

vmcpServer := v1beta1test.NewVirtualMCPServer("test-vmcp", "default",
v1beta1test.WithVMCPGroupRef("test-group"),
v1beta1test.WithVMCPIncomingAuth(&mcpv1beta1.IncomingAuthConfig{Type: "anonymous"}),
v1beta1test.WithVMCPConfig(vmcpconfig.Config{
Aggregation: &vmcpconfig.AggregationConfig{
DefaultToolVisibility: tt.set,
Tools: []*vmcpconfig.WorkloadToolConfig{{Workload: "backend1"}},
},
}),
)

ctx := log.IntoContext(context.Background(), logr.Discard())
converter := newTestConverter(t, newNoOpMockResolver(t))
converter.k8sClient = newTestK8sClient(t)

got, _, err := converter.Convert(ctx, vmcpServer, nil)
require.NoError(t, err)
require.NotNil(t, got)
require.NotNil(t, got.Aggregation)
assert.Equal(t, tt.want, got.Aggregation.DefaultToolVisibility,
"defaultToolVisibility must survive CRD-to-config conversion")
})
}
}

// TestConverter_InlineTelemetryIgnored verifies that the operator-side converter
// ignores Config.Telemetry (the standalone CLI field) and only uses TelemetryConfigRef.
func TestConverter_InlineTelemetryIgnored(t *testing.T) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,35 @@ spec:
type: string
type: array
type: object
defaultToolVisibility:
description: |-
DefaultToolVisibility controls whether a backend with NO entry in Tools has its
tools advertised to MCP clients.
- allow (default): every tool from an unlisted backend is advertised, so
adding a workload to the group exposes it without further configuration.
- deny: an unlisted backend contributes no tools, so only backends named in
Tools are advertised. Use this when the set of exposed tools must be
enumerated deliberately rather than inherited from group membership.

A backend that DOES have a Tools entry is unaffected by this setting: the
entry opts it in, and ExcludeAll/Filter on that entry decide the rest. Like
every other visibility setting here, this controls advertising only — hidden
tools remain in the routing table for composite tools (see the type doc).

This gates TOOLS only. An unlisted backend's resources, resource templates,
and prompts are still advertised under deny; mergeResources/mergePrompts have
no equivalent check.

No kubebuilder default: "" already behaves as allow everywhere that reads this
field, so defaulting would change only the serialized bytes — and apiextensions
applies structural defaults on decode, so every existing VirtualMCPServer would
come back with the field set, changing config.yaml, its ConfigMap checksum, and
the pod template that stamps it. That restarts every vMCP deployment once for a
no-op field.
enum:
- allow
- deny
type: string
excludeAllTools:
description: |-
ExcludeAllTools hides all backend tools from MCP clients when true.
Expand Down Expand Up @@ -4496,6 +4525,35 @@ spec:
type: string
type: array
type: object
defaultToolVisibility:
description: |-
DefaultToolVisibility controls whether a backend with NO entry in Tools has its
tools advertised to MCP clients.
- allow (default): every tool from an unlisted backend is advertised, so
adding a workload to the group exposes it without further configuration.
- deny: an unlisted backend contributes no tools, so only backends named in
Tools are advertised. Use this when the set of exposed tools must be
enumerated deliberately rather than inherited from group membership.

A backend that DOES have a Tools entry is unaffected by this setting: the
entry opts it in, and ExcludeAll/Filter on that entry decide the rest. Like
every other visibility setting here, this controls advertising only — hidden
tools remain in the routing table for composite tools (see the type doc).

This gates TOOLS only. An unlisted backend's resources, resource templates,
and prompts are still advertised under deny; mergeResources/mergePrompts have
no equivalent check.

No kubebuilder default: "" already behaves as allow everywhere that reads this
field, so defaulting would change only the serialized bytes — and apiextensions
applies structural defaults on decode, so every existing VirtualMCPServer would
come back with the field set, changing config.yaml, its ConfigMap checksum, and
the pod template that stamps it. That restarts every vMCP deployment once for a
no-op field.
enum:
- allow
- deny
type: string
excludeAllTools:
description: |-
ExcludeAllTools hides all backend tools from MCP clients when true.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1035,6 +1035,35 @@ spec:
type: string
type: array
type: object
defaultToolVisibility:
description: |-
DefaultToolVisibility controls whether a backend with NO entry in Tools has its
tools advertised to MCP clients.
- allow (default): every tool from an unlisted backend is advertised, so
adding a workload to the group exposes it without further configuration.
- deny: an unlisted backend contributes no tools, so only backends named in
Tools are advertised. Use this when the set of exposed tools must be
enumerated deliberately rather than inherited from group membership.

A backend that DOES have a Tools entry is unaffected by this setting: the
entry opts it in, and ExcludeAll/Filter on that entry decide the rest. Like
every other visibility setting here, this controls advertising only — hidden
tools remain in the routing table for composite tools (see the type doc).

This gates TOOLS only. An unlisted backend's resources, resource templates,
and prompts are still advertised under deny; mergeResources/mergePrompts have
no equivalent check.

No kubebuilder default: "" already behaves as allow everywhere that reads this
field, so defaulting would change only the serialized bytes — and apiextensions
applies structural defaults on decode, so every existing VirtualMCPServer would
come back with the field set, changing config.yaml, its ConfigMap checksum, and
the pod template that stamps it. That restarts every vMCP deployment once for a
no-op field.
enum:
- allow
- deny
type: string
excludeAllTools:
description: |-
ExcludeAllTools hides all backend tools from MCP clients when true.
Expand Down Expand Up @@ -4499,6 +4528,35 @@ spec:
type: string
type: array
type: object
defaultToolVisibility:
description: |-
DefaultToolVisibility controls whether a backend with NO entry in Tools has its
tools advertised to MCP clients.
- allow (default): every tool from an unlisted backend is advertised, so
adding a workload to the group exposes it without further configuration.
- deny: an unlisted backend contributes no tools, so only backends named in
Tools are advertised. Use this when the set of exposed tools must be
enumerated deliberately rather than inherited from group membership.

A backend that DOES have a Tools entry is unaffected by this setting: the
entry opts it in, and ExcludeAll/Filter on that entry decide the rest. Like
every other visibility setting here, this controls advertising only — hidden
tools remain in the routing table for composite tools (see the type doc).

This gates TOOLS only. An unlisted backend's resources, resource templates,
and prompts are still advertised under deny; mergeResources/mergePrompts have
no equivalent check.

No kubebuilder default: "" already behaves as allow everywhere that reads this
field, so defaulting would change only the serialized bytes — and apiextensions
applies structural defaults on decode, so every existing VirtualMCPServer would
come back with the field set, changing config.yaml, its ConfigMap checksum, and
the pod template that stamps it. That restarts every vMCP deployment once for a
no-op field.
enum:
- allow
- deny
type: string
excludeAllTools:
description: |-
ExcludeAllTools hides all backend tools from MCP clients when true.
Expand Down
13 changes: 13 additions & 0 deletions docs/arch/10-virtual-mcp-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,19 @@ scan silently dropped an item whenever a duplicate landed on a page boundary

Beyond conflict resolution, vMCP can filter which tools are exposed through allow/deny lists, renaming, and description overrides.

By default a backend with no per-workload entry has all of its tools advertised, so
adding a workload to the group exposes it. `aggregation.defaultToolVisibility: deny`
inverts that, advertising only backends named in `aggregation.tools` — useful when the
exposed tool set should be enumerated deliberately rather than inherited from group
membership. A listed backend is opted in by its entry; its own `excludeAll`/`filter`
then decide which of its tools are advertised.

All of these settings — `excludeAllTools`, `defaultToolVisibility`, per-workload
`excludeAll`, and `filter` — control **advertising only**. Every backend tool stays in
the routing table so composite tools can call hidden ones, and none of them affect
resources or prompts. Per-identity authorization is Cedar's job (see [Authorization
Enforcement](#authorization-enforcement-core-admission-seam--pre-dispatch-gate)).

**Implementation**: `pkg/vmcp/aggregator/`

## Composite Tools
Expand Down
24 changes: 22 additions & 2 deletions docs/operator/crd-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading