Skip to content

Commit 7d4a1db

Browse files
committed
fix and test mcptoolconfig for vmcp
Add proper reconciliation, dynamic updates and overrides support Add tests to validate it
1 parent 72268b7 commit 7d4a1db

File tree

7 files changed

+699
-31
lines changed

7 files changed

+699
-31
lines changed

cmd/thv-operator/controllers/virtualmcpserver_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ func (r *VirtualMCPServerReconciler) podTemplateSpecNeedsUpdate(
834834
}
835835

836836
// Generate a fresh deployment with PodTemplateSpec applied
837-
expectedDeployment := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum)
837+
expectedDeployment := r.deploymentForVirtualMCPServer(ctx, vmcp, vmcpConfigChecksum, []string{})
838838
if expectedDeployment == nil {
839839
// If we can't generate expected deployment, assume update is needed
840840
return true

cmd/thv-operator/controllers/virtualmcpserver_podtemplatespec_reconcile_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ func TestVirtualMCPServerPodTemplateSpecDeterministic(t *testing.T) {
8989
}
9090

9191
// Generate deployment twice with same input
92-
dep1 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum")
93-
dep2 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum")
92+
dep1 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []string{})
93+
dep2 := reconciler.deploymentForVirtualMCPServer(context.Background(), vmcp, "test-checksum", []string{})
9494

9595
// Both should be non-nil
9696
assert.NotNil(t, dep1, "First deployment should not be nil")
@@ -219,7 +219,7 @@ func TestVirtualMCPServerPodTemplateSpecNeedsUpdate(t *testing.T) {
219219
}
220220

221221
// Generate existing deployment using the reconciler (this ensures we have a real deployment structure)
222-
existingDeployment := reconciler.deploymentForVirtualMCPServer(context.Background(), initialVmcp, "test-checksum")
222+
existingDeployment := reconciler.deploymentForVirtualMCPServer(context.Background(), initialVmcp, "test-checksum", []string{})
223223
assert.NotNil(t, existingDeployment, "Should generate existing deployment")
224224

225225
// Create VirtualMCPServer with new PodTemplateSpec

cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap(
3131
// Create OIDC resolver to handle all OIDC types (kubernetes, configMap, inline)
3232
oidcResolver := oidc.NewResolver(r.Client)
3333

34-
// Convert CRD to vmcp config using converter with OIDC resolver
35-
converter, err := vmcpconfig.NewConverter(oidcResolver)
34+
// Convert CRD to vmcp config using converter with OIDC resolver and k8s client
35+
converter, err := vmcpconfig.NewConverter(oidcResolver, r.Client)
3636
if err != nil {
3737
return fmt.Errorf("failed to create vmcp converter: %w", err)
3838
}

cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver {
4747
// newTestConverter creates a Converter with the given resolver, failing the test if creation fails.
4848
func newTestConverter(t *testing.T, resolver *oidcmocks.MockResolver) *vmcpconfig.Converter {
4949
t.Helper()
50-
converter, err := vmcpconfig.NewConverter(resolver)
50+
// Create a fake k8s client for testing
51+
scheme := runtime.NewScheme()
52+
_ = mcpv1alpha1.AddToScheme(scheme)
53+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
54+
converter, err := vmcpconfig.NewConverter(resolver, fakeClient)
5155
require.NoError(t, err)
5256
return converter
5357
}

cmd/thv-operator/pkg/vmcpconfig/converter.go

Lines changed: 142 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"fmt"
88
"time"
99

10+
"github.com/go-logr/logr"
11+
"k8s.io/apimachinery/pkg/types"
12+
"sigs.k8s.io/controller-runtime/pkg/client"
1013
"sigs.k8s.io/controller-runtime/pkg/log"
1114

1215
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
@@ -29,18 +32,24 @@ const (
2932
// Converter converts VirtualMCPServer CRD specs to vmcp Config
3033
type Converter struct {
3134
oidcResolver oidc.Resolver
35+
k8sClient client.Client
3236
}
3337

3438
// NewConverter creates a new Converter instance.
3539
// oidcResolver is required and used to resolve OIDC configuration from various sources
3640
// (kubernetes, configMap, inline). Use a mock resolver in tests.
37-
// Returns an error if oidcResolver is nil.
38-
func NewConverter(oidcResolver oidc.Resolver) (*Converter, error) {
41+
// k8sClient is required for resolving MCPToolConfig references.
42+
// Returns an error if oidcResolver or k8sClient is nil.
43+
func NewConverter(oidcResolver oidc.Resolver, k8sClient client.Client) (*Converter, error) {
3944
if oidcResolver == nil {
4045
return nil, fmt.Errorf("oidcResolver is required")
4146
}
47+
if k8sClient == nil {
48+
return nil, fmt.Errorf("k8sClient is required")
49+
}
4250
return &Converter{
4351
oidcResolver: oidcResolver,
52+
k8sClient: k8sClient,
4453
}, nil
4554
}
4655

@@ -243,12 +252,23 @@ func (*Converter) convertBackendAuthConfig(
243252
}
244253

245254
// convertAggregation converts AggregationConfig from CRD to vmcp config
246-
func (*Converter) convertAggregation(
247-
_ context.Context,
255+
func (c *Converter) convertAggregation(
256+
ctx context.Context,
248257
vmcp *mcpv1alpha1.VirtualMCPServer,
249258
) *vmcpconfig.AggregationConfig {
250259
agg := &vmcpconfig.AggregationConfig{}
251260

261+
c.convertConflictResolution(vmcp, agg)
262+
c.convertToolConfigs(ctx, vmcp, agg)
263+
264+
return agg
265+
}
266+
267+
// convertConflictResolution converts conflict resolution strategy and config
268+
func (*Converter) convertConflictResolution(
269+
vmcp *mcpv1alpha1.VirtualMCPServer,
270+
agg *vmcpconfig.AggregationConfig,
271+
) {
252272
// Convert conflict resolution strategy
253273
switch vmcp.Spec.Aggregation.ConflictResolution {
254274
case mcpv1alpha1.ConflictResolutionPrefix:
@@ -273,32 +293,131 @@ func (*Converter) convertAggregation(
273293
PrefixFormat: "{workload}_",
274294
}
275295
}
296+
}
276297

277-
// Convert per-workload tool configs
278-
if len(vmcp.Spec.Aggregation.Tools) > 0 {
279-
agg.Tools = make([]*vmcpconfig.WorkloadToolConfig, 0, len(vmcp.Spec.Aggregation.Tools))
280-
for _, toolConfig := range vmcp.Spec.Aggregation.Tools {
281-
wtc := &vmcpconfig.WorkloadToolConfig{
282-
Workload: toolConfig.Workload,
283-
Filter: toolConfig.Filter,
284-
}
298+
// convertToolConfigs converts per-workload tool configurations
299+
func (c *Converter) convertToolConfigs(
300+
ctx context.Context,
301+
vmcp *mcpv1alpha1.VirtualMCPServer,
302+
agg *vmcpconfig.AggregationConfig,
303+
) {
304+
if len(vmcp.Spec.Aggregation.Tools) == 0 {
305+
return
306+
}
285307

286-
// Convert overrides
287-
if len(toolConfig.Overrides) > 0 {
288-
wtc.Overrides = make(map[string]*vmcpconfig.ToolOverride)
289-
for toolName, override := range toolConfig.Overrides {
290-
wtc.Overrides[toolName] = &vmcpconfig.ToolOverride{
291-
Name: override.Name,
292-
Description: override.Description,
293-
}
294-
}
308+
ctxLogger := log.FromContext(ctx)
309+
agg.Tools = make([]*vmcpconfig.WorkloadToolConfig, 0, len(vmcp.Spec.Aggregation.Tools))
310+
311+
for _, toolConfig := range vmcp.Spec.Aggregation.Tools {
312+
wtc := &vmcpconfig.WorkloadToolConfig{
313+
Workload: toolConfig.Workload,
314+
Filter: toolConfig.Filter,
315+
}
316+
317+
c.applyToolConfigRef(ctx, ctxLogger, vmcp, toolConfig, wtc)
318+
c.applyInlineOverrides(toolConfig, wtc)
319+
320+
agg.Tools = append(agg.Tools, wtc)
321+
}
322+
}
323+
324+
// applyToolConfigRef resolves and applies MCPToolConfig reference
325+
func (c *Converter) applyToolConfigRef(
326+
ctx context.Context,
327+
ctxLogger logr.Logger,
328+
vmcp *mcpv1alpha1.VirtualMCPServer,
329+
toolConfig mcpv1alpha1.WorkloadToolConfig,
330+
wtc *vmcpconfig.WorkloadToolConfig,
331+
) {
332+
if toolConfig.ToolConfigRef == nil {
333+
return
334+
}
335+
336+
resolvedConfig, err := c.resolveMCPToolConfig(ctx, vmcp.Namespace, toolConfig.ToolConfigRef.Name)
337+
if err != nil {
338+
ctxLogger.Error(err, "failed to resolve MCPToolConfig reference",
339+
"workload", toolConfig.Workload,
340+
"toolConfigRef", toolConfig.ToolConfigRef.Name)
341+
return
342+
}
343+
344+
if resolvedConfig == nil {
345+
return
346+
}
347+
348+
c.mergeToolConfigFilter(wtc, resolvedConfig)
349+
c.mergeToolConfigOverrides(wtc, resolvedConfig)
350+
}
351+
352+
// mergeToolConfigFilter merges filter from MCPToolConfig
353+
func (*Converter) mergeToolConfigFilter(
354+
wtc *vmcpconfig.WorkloadToolConfig,
355+
resolvedConfig *mcpv1alpha1.MCPToolConfig,
356+
) {
357+
if len(wtc.Filter) == 0 && len(resolvedConfig.Spec.ToolsFilter) > 0 {
358+
wtc.Filter = resolvedConfig.Spec.ToolsFilter
359+
}
360+
}
361+
362+
// mergeToolConfigOverrides merges overrides from MCPToolConfig
363+
func (*Converter) mergeToolConfigOverrides(
364+
wtc *vmcpconfig.WorkloadToolConfig,
365+
resolvedConfig *mcpv1alpha1.MCPToolConfig,
366+
) {
367+
if len(resolvedConfig.Spec.ToolsOverride) == 0 {
368+
return
369+
}
370+
371+
if wtc.Overrides == nil {
372+
wtc.Overrides = make(map[string]*vmcpconfig.ToolOverride)
373+
}
374+
375+
for toolName, override := range resolvedConfig.Spec.ToolsOverride {
376+
if _, exists := wtc.Overrides[toolName]; !exists {
377+
wtc.Overrides[toolName] = &vmcpconfig.ToolOverride{
378+
Name: override.Name,
379+
Description: override.Description,
295380
}
381+
}
382+
}
383+
}
384+
385+
// applyInlineOverrides applies inline tool overrides
386+
func (*Converter) applyInlineOverrides(
387+
toolConfig mcpv1alpha1.WorkloadToolConfig,
388+
wtc *vmcpconfig.WorkloadToolConfig,
389+
) {
390+
if len(toolConfig.Overrides) == 0 {
391+
return
392+
}
296393

297-
agg.Tools = append(agg.Tools, wtc)
394+
if wtc.Overrides == nil {
395+
wtc.Overrides = make(map[string]*vmcpconfig.ToolOverride)
396+
}
397+
398+
for toolName, override := range toolConfig.Overrides {
399+
wtc.Overrides[toolName] = &vmcpconfig.ToolOverride{
400+
Name: override.Name,
401+
Description: override.Description,
298402
}
299403
}
404+
}
300405

301-
return agg
406+
// resolveMCPToolConfig fetches an MCPToolConfig resource by name and namespace
407+
func (c *Converter) resolveMCPToolConfig(
408+
ctx context.Context,
409+
namespace string,
410+
name string,
411+
) (*mcpv1alpha1.MCPToolConfig, error) {
412+
toolConfig := &mcpv1alpha1.MCPToolConfig{}
413+
err := c.k8sClient.Get(ctx, types.NamespacedName{
414+
Name: name,
415+
Namespace: namespace,
416+
}, toolConfig)
417+
if err != nil {
418+
return nil, fmt.Errorf("failed to get MCPToolConfig %s/%s: %w", namespace, name, err)
419+
}
420+
return toolConfig, nil
302421
}
303422

304423
// convertCompositeTools converts CompositeToolSpec from CRD to vmcp config

cmd/thv-operator/pkg/vmcpconfig/converter_test.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
"go.uber.org/mock/gomock"
1414
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1515
"k8s.io/apimachinery/pkg/runtime"
16+
"sigs.k8s.io/controller-runtime/pkg/client"
17+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
1618
"sigs.k8s.io/controller-runtime/pkg/log"
1719

1820
mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
@@ -36,10 +38,19 @@ func newNoOpMockResolver(t *testing.T) *oidcmocks.MockResolver {
3638
return mockResolver
3739
}
3840

41+
// newTestK8sClient creates a fake Kubernetes client for testing.
42+
func newTestK8sClient(t *testing.T, objects ...client.Object) client.Client {
43+
t.Helper()
44+
scheme := runtime.NewScheme()
45+
require.NoError(t, mcpv1alpha1.AddToScheme(scheme))
46+
return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build()
47+
}
48+
3949
// newTestConverter creates a Converter with the given resolver, failing the test if creation fails.
4050
func newTestConverter(t *testing.T, resolver oidc.Resolver) *Converter {
4151
t.Helper()
42-
converter, err := NewConverter(resolver)
52+
k8sClient := newTestK8sClient(t)
53+
converter, err := NewConverter(resolver, k8sClient)
4354
require.NoError(t, err)
4455
return converter
4556
}

0 commit comments

Comments
 (0)