Skip to content

Commit b213474

Browse files
authored
Add tool filtering to MCPServer CRD. (#1176)
This change adds support for `tools` to `MCPServer` CRD. By default, the list is `nil` which defaults to no `--tools` option being passed as argument to the container. When a non-empty list is passed, it is interpolated ensuring the items are sorted first, so that changing the sequence of tools being enabled does not trigger a reconciliation. A simple unit test was added to verify the various cases of reconciliation, but I also tested it locally using `kind` and a debugger. Fixes #1005
1 parent b67b663 commit b213474

File tree

14 files changed

+152
-9
lines changed

14 files changed

+152
-9
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
4ce3c9a46c7a123dc6af67746ae657c1

cmd/thv-operator/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ kubectl describe mcpserver <name>
161161

162162
### MCPServer Spec
163163

164-
| Field | Description | Required | Default |
164+
| Field | Description | Required | Default |
165165
|---------------------|--------------------------------------------------|----------|---------|
166166
| `image` | Container image for the MCP server | Yes | - |
167167
| `transport` | Transport method (stdio, streamable-http or sse) | No | stdio |
@@ -173,6 +173,7 @@ kubectl describe mcpserver <name>
173173
| `resources` | Resource requirements for the container | No | - |
174174
| `secrets` | References to secrets to mount in the container | No | - |
175175
| `permissionProfile` | Permission profile configuration | No | - |
176+
| `tools` | Allow-list filter on the list of tools | No | - |
176177

177178
### Permission Profiles
178179

@@ -255,4 +256,4 @@ The Kubebuilder project structure is as follows:
255256
- `config/`: Contains the Kubernetes manifests for deploying the operator
256257
- `PROJECT`: Kubebuilder project configuration file
257258

258-
For more information on Kubebuilder, see the [Kubebuilder Book](https://book.kubebuilder.io/).
259+
For more information on Kubebuilder, see the [Kubebuilder Book](https://book.kubebuilder.io/).

cmd/thv-operator/api/v1alpha1/mcpserver_types.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ type MCPServerSpec struct {
7070
// AuthzConfig defines authorization policy configuration for the MCP server
7171
// +optional
7272
AuthzConfig *AuthzConfigRef `json:"authzConfig,omitempty"`
73+
74+
// ToolsFilter is the filter on tools applied to the MCP server
75+
// +optional
76+
ToolsFilter []string `json:"tools,omitempty"`
7377
}
7478

7579
// ResourceOverrides defines overrides for annotations and labels on created resources

cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/controllers/mcpserver_controller.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"maps"
1010
"os"
1111
"reflect"
12+
"slices"
1213
"strings"
1314
"time"
1415

@@ -440,6 +441,12 @@ func (r *MCPServerReconciler) deploymentForMCPServer(m *mcpv1alpha1.MCPServer) *
440441
args = append(args, fmt.Sprintf("--env=%s=%s", e.Name, e.Value))
441442
}
442443

444+
// Add tools filter args
445+
if len(m.Spec.ToolsFilter) > 0 {
446+
slices.Sort(m.Spec.ToolsFilter)
447+
args = append(args, fmt.Sprintf("--tools=%s", strings.Join(m.Spec.ToolsFilter, ",")))
448+
}
449+
443450
// Add the image
444451
args = append(args, m.Spec.Image)
445452

@@ -805,6 +812,22 @@ func deploymentNeedsUpdate(deployment *appsv1.Deployment, mcpServer *mcpv1alpha1
805812
return true
806813
}
807814

815+
// Check if the tools filter has changed
816+
if mcpServer.Spec.ToolsFilter == nil {
817+
for _, arg := range container.Args {
818+
if strings.HasPrefix(arg, "--tools=") {
819+
return true
820+
}
821+
}
822+
} else {
823+
slices.Sort(mcpServer.Spec.ToolsFilter)
824+
toolsFilterArg := fmt.Sprintf("--tools=%s", strings.Join(mcpServer.Spec.ToolsFilter, ","))
825+
found = slices.Contains(container.Args, toolsFilterArg)
826+
if !found {
827+
return true
828+
}
829+
}
830+
808831
// Check if the pod template spec has changed
809832

810833
// TODO: Add more comprehensive checks for PodTemplateSpec changes beyond just the args

cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,3 +577,74 @@ func TestDeploymentNeedsUpdateProxyEnv(t *testing.T) {
577577
})
578578
}
579579
}
580+
581+
func TestDeploymentNeedsUpdateToolsFilter(t *testing.T) {
582+
t.Parallel()
583+
584+
scheme := runtime.NewScheme()
585+
require.NoError(t, mcpv1alpha1.AddToScheme(scheme))
586+
587+
client := fake.NewClientBuilder().WithScheme(scheme).Build()
588+
r := &MCPServerReconciler{
589+
Client: client,
590+
Scheme: scheme,
591+
}
592+
593+
tests := []struct {
594+
name string
595+
initialToolsFilter []string
596+
newToolsFilter []string
597+
expectEnvChange bool
598+
}{
599+
{
600+
name: "empty tools filter",
601+
initialToolsFilter: nil,
602+
newToolsFilter: nil,
603+
expectEnvChange: false,
604+
},
605+
{
606+
name: "tools filter not changed",
607+
initialToolsFilter: []string{"tool1", "tool2"},
608+
newToolsFilter: []string{"tool1", "tool2"},
609+
expectEnvChange: false,
610+
},
611+
{
612+
name: "tools filter changed",
613+
initialToolsFilter: []string{"tool1", "tool2"},
614+
newToolsFilter: []string{"tool2", "tool3"},
615+
expectEnvChange: true,
616+
},
617+
{
618+
name: "tools filter change order",
619+
initialToolsFilter: []string{"tool1", "tool2"},
620+
newToolsFilter: []string{"tool2", "tool1"},
621+
expectEnvChange: false,
622+
},
623+
}
624+
625+
for _, tt := range tests {
626+
t.Run(tt.name, func(t *testing.T) {
627+
t.Parallel()
628+
629+
mcpServer := &mcpv1alpha1.MCPServer{
630+
ObjectMeta: metav1.ObjectMeta{
631+
Name: "test-server",
632+
Namespace: "default",
633+
},
634+
Spec: mcpv1alpha1.MCPServerSpec{
635+
Image: "test-image",
636+
Port: 8080,
637+
ToolsFilter: tt.initialToolsFilter,
638+
},
639+
}
640+
641+
deployment := r.deploymentForMCPServer(mcpServer)
642+
require.NotNil(t, deployment)
643+
644+
mcpServer.Spec.ToolsFilter = tt.newToolsFilter
645+
646+
needsUpdate := deploymentNeedsUpdate(deployment, mcpServer)
647+
assert.Equal(t, tt.expectEnvChange, needsUpdate)
648+
})
649+
}
650+
}

cmd/thv-proxyrunner/app/run.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ var (
7474

7575
// Network isolation flag
7676
runIsolateNetwork bool
77+
78+
// Tools filter
79+
runToolsFilter []string
7780
)
7881

7982
func init() {
@@ -176,7 +179,12 @@ func init() {
176179
"Enable Prometheus-style /metrics endpoint on the main transport port")
177180
runCmd.Flags().BoolVar(&runIsolateNetwork, "isolate-network", false,
178181
"Isolate the container network from the host (default: false)")
179-
182+
runCmd.Flags().StringArrayVar(
183+
&runToolsFilter,
184+
"tools",
185+
nil,
186+
"Filter MCP server tools (comma-separated list of tool names)",
187+
)
180188
}
181189

182190
func runCmdFunc(cmd *cobra.Command, args []string) error {
@@ -243,6 +251,7 @@ func runCmdFunc(cmd *cobra.Command, args []string) error {
243251
runThvCABundle, runJWKSAuthTokenFile, runJWKSAllowPrivateIP).
244252
WithTelemetryConfig(finalOtelEndpoint, runOtelEnablePrometheusMetricsPath, runOtelServiceName,
245253
finalOtelSamplingRate, runOtelHeaders, runOtelInsecure, finalOtelEnvironmentVariables).
254+
WithToolsFilter(runToolsFilter).
246255
Build(ctx, imageMetadata, runEnv, envVarValidator)
247256
if err != nil {
248257
return fmt.Errorf("failed to create RunConfig: %v", err)

deploy/charts/operator-crds/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: toolhive-operator-crds
33
description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.
44
type: application
5-
version: 0.0.11
5+
version: 0.0.12
66
appVersion: "0.0.1"

deploy/charts/operator-crds/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
# ToolHive Operator CRDs Helm Chart
33

4-
![Version: 0.0.11](https://img.shields.io/badge/Version-0.0.11-informational?style=flat-square)
4+
![Version: 0.0.12](https://img.shields.io/badge/Version-0.0.12-informational?style=flat-square)
55
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
66

77
A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpservers.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8405,6 +8405,12 @@ spec:
84058405
maximum: 65535
84068406
minimum: 1
84078407
type: integer
8408+
tools:
8409+
description: ToolsFilter is the filter on tools applied to the MCP
8410+
server
8411+
items:
8412+
type: string
8413+
type: array
84088414
transport:
84098415
default: stdio
84108416
description: Transport is the transport method for the MCP server

0 commit comments

Comments
 (0)