Skip to content

Commit 0c5213e

Browse files
ChrisJBurnsclaude
andauthored
Change GroupRef from bare string to typed MCPGroupRef struct (#4809)
* Change GroupRef from bare string to typed MCPGroupRef struct GroupRef was a bare string while every other cross-CRD reference (ExternalAuthConfigRef, ToolConfigRef, AuthServerRef, EmbeddingServerRef) uses a typed struct. This inconsistency prevented extending GroupRef with additional fields (like namespace) without a breaking change. Define MCPGroupRef struct with Name field and nil-safe GetName() helper. Replace GroupRef string with *MCPGroupRef on MCPServerSpec, MCPRemoteProxySpec, MCPServerEntrySpec, and add a new top-level GroupRef field on VirtualMCPServerSpec that takes precedence over the deprecated config.groupRef string path. Internal types (vmcpserver.Config, StatusResponse, BackendReconciler, config.Config) remain as strings since they are not CRD API types. This is a breaking wire-format change (groupRef: "name" becomes groupRef: {name: "name"}) that must happen before v1beta1. Closes #4634 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review feedback on MCPGroupRef refactoring - Add nil guard to MCPServerEntry validateGroupRef using GetName() instead of direct .Name access to prevent potential panic - Add unit tests for MCPGroupRef.GetName() and VirtualMCPServer.ResolveGroupName() covering precedence logic - Normalize nil-check pattern across all field index extractors to use GetName() == "" (handles both nil and empty name) - Mark config.Config.Group as optional and deprecated since spec.groupRef is now the preferred path - Improve Validate() error message to mention both spec.groupRef and config.groupRef paths Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Update missed example and docs for MCPGroupRef wire format - Fix vmcp_with_telemetry_ref.yaml (missed in initial pass) - Update virtualmcpserver-api.md to document spec.groupRef as primary field and update all YAML examples to struct format - Update virtualmcpcompositetooldefinition-guide.md and virtualmcpserver-observability.md examples - Regenerate crd-api.md to show MCPGroupRef typed struct Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent be40131 commit 0c5213e

78 files changed

Lines changed: 804 additions & 488 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ type MCPRemoteProxySpec struct {
143143
// +optional
144144
ResourceOverrides *ResourceOverrides `json:"resourceOverrides,omitempty"`
145145

146-
// GroupRef is the name of the MCPGroup this proxy belongs to
147-
// Must reference an existing MCPGroup in the same namespace
146+
// GroupRef references the MCPGroup this proxy belongs to.
147+
// The referenced MCPGroup must be in the same namespace.
148148
// +optional
149-
GroupRef string `json:"groupRef,omitempty"`
149+
GroupRef *MCPGroupRef `json:"groupRef,omitempty"`
150150

151151
// SessionAffinity controls whether the Service routes repeated client connections to the same pod.
152152
// MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,10 @@ type MCPServerSpec struct {
328328
// +optional
329329
EndpointPrefix string `json:"endpointPrefix,omitempty"`
330330

331-
// GroupRef is the name of the MCPGroup this server belongs to
332-
// Must reference an existing MCPGroup in the same namespace
331+
// GroupRef references the MCPGroup this server belongs to.
332+
// The referenced MCPGroup must be in the same namespace.
333333
// +optional
334-
GroupRef string `json:"groupRef,omitempty"`
334+
GroupRef *MCPGroupRef `json:"groupRef,omitempty"`
335335

336336
// SessionAffinity controls whether the Service routes repeated client connections to the same pod.
337337
// MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
@@ -911,6 +911,23 @@ type ToolConfigRef struct {
911911
Name string `json:"name"`
912912
}
913913

914+
// MCPGroupRef defines a reference to an MCPGroup resource.
915+
// The referenced MCPGroup must be in the same namespace.
916+
type MCPGroupRef struct {
917+
// Name is the name of the MCPGroup resource in the same namespace
918+
// +kubebuilder:validation:Required
919+
// +kubebuilder:validation:MinLength=1
920+
Name string `json:"name"`
921+
}
922+
923+
// GetName returns the name, or empty string if the receiver is nil.
924+
func (r *MCPGroupRef) GetName() string {
925+
if r == nil {
926+
return ""
927+
}
928+
return r.Name
929+
}
930+
914931
// InlineAuthzConfig contains direct authorization configuration
915932
type InlineAuthzConfig struct {
916933
// Policies is a list of Cedar policy strings

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,10 @@ type MCPServerEntrySpec struct {
2424
// +kubebuilder:validation:Enum=sse;streamable-http
2525
Transport string `json:"transport"`
2626

27-
// GroupRef is the name of the MCPGroup this entry belongs to.
27+
// GroupRef references the MCPGroup this entry belongs to.
2828
// Required — every MCPServerEntry must be part of a group for vMCP discovery.
2929
// +kubebuilder:validation:Required
30-
// +kubebuilder:validation:MinLength=1
31-
GroupRef string `json:"groupRef"`
30+
GroupRef *MCPGroupRef `json:"groupRef"`
3231

3332
// ExternalAuthConfigRef references a MCPExternalAuthConfig resource for token exchange
3433
// when connecting to the remote MCP server. The referenced MCPExternalAuthConfig must
@@ -155,7 +154,7 @@ const (
155154
//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
156155
//+kubebuilder:printcolumn:name="Transport",type="string",JSONPath=".spec.transport"
157156
//+kubebuilder:printcolumn:name="Remote URL",type="string",JSONPath=".spec.remoteUrl"
158-
//+kubebuilder:printcolumn:name="Group",type="string",JSONPath=".spec.groupRef"
157+
//+kubebuilder:printcolumn:name="Group",type="string",JSONPath=".spec.groupRef.name"
159158
//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
160159

161160
// MCPServerEntry is the Schema for the mcpserverentries API.

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,15 @@ type VirtualMCPServerSpec struct {
6363
// +kubebuilder:validation:Type=object
6464
PodTemplateSpec *runtime.RawExtension `json:"podTemplateSpec,omitempty"`
6565

66-
// Config is the Virtual MCP server configuration
67-
// The only field currently required within config is `config.groupRef`.
68-
// GroupRef references an existing MCPGroup that defines backend workloads.
66+
// GroupRef references the MCPGroup that defines backend workloads.
6967
// The referenced MCPGroup must exist in the same namespace.
68+
// This field takes precedence over config.groupRef and should be preferred.
69+
// +optional
70+
GroupRef *MCPGroupRef `json:"groupRef,omitempty"`
71+
72+
// Config is the Virtual MCP server configuration.
7073
// The audit config from here is also supported, but not required.
74+
// Note: config.groupRef is deprecated in favor of spec.groupRef.
7175
// Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference
7276
// a shared MCPTelemetryConfig resource instead.
7377
// +optional
@@ -447,13 +451,22 @@ func (*VirtualMCPServer) GetProxyPort() int32 {
447451
return 4483
448452
}
449453

454+
// ResolveGroupName returns the effective group name, preferring spec.groupRef
455+
// over the deprecated config.groupRef string.
456+
func (r *VirtualMCPServer) ResolveGroupName() string {
457+
if name := r.Spec.GroupRef.GetName(); name != "" {
458+
return name
459+
}
460+
return r.Spec.Config.Group
461+
}
462+
450463
// Validate performs validation for VirtualMCPServer
451464
// This method is called by the controller during reconciliation
452465
func (r *VirtualMCPServer) Validate() error {
453-
// Validate Group is set (required field)
466+
// Validate Group is set — prefer spec.groupRef, fall back to config.groupRef
454467
// Note: CEL cannot validate embedded types from other packages
455-
if r.Spec.Config.Group == "" {
456-
return fmt.Errorf("spec.config.groupRef is required")
468+
if r.Spec.GroupRef.GetName() == "" && r.Spec.Config.Group == "" {
469+
return fmt.Errorf("either spec.groupRef.name or config.groupRef must be set")
457470
}
458471

459472
// Note: IncomingAuth validation is handled by kubebuilder markers and CEL rules

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,3 +535,71 @@ func TestVirtualMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) {
535535
})
536536
}
537537
}
538+
539+
func TestMCPGroupRef_GetName(t *testing.T) {
540+
t.Parallel()
541+
542+
tests := []struct {
543+
name string
544+
ref *MCPGroupRef
545+
want string
546+
}{
547+
{name: "nil receiver", ref: nil, want: ""},
548+
{name: "empty name", ref: &MCPGroupRef{Name: ""}, want: ""},
549+
{name: "non-empty name", ref: &MCPGroupRef{Name: "my-group"}, want: "my-group"},
550+
}
551+
for _, tt := range tests {
552+
t.Run(tt.name, func(t *testing.T) {
553+
t.Parallel()
554+
assert.Equal(t, tt.want, tt.ref.GetName())
555+
})
556+
}
557+
}
558+
559+
func TestVirtualMCPServer_ResolveGroupName(t *testing.T) {
560+
t.Parallel()
561+
562+
tests := []struct {
563+
name string
564+
groupRef *MCPGroupRef
565+
cfgGroup string
566+
want string
567+
}{
568+
{
569+
name: "spec.groupRef takes precedence over config.groupRef",
570+
groupRef: &MCPGroupRef{Name: "from-spec"},
571+
cfgGroup: "from-config",
572+
want: "from-spec",
573+
},
574+
{
575+
name: "falls back to config.groupRef when spec.groupRef is nil",
576+
groupRef: nil,
577+
cfgGroup: "from-config",
578+
want: "from-config",
579+
},
580+
{
581+
name: "only spec.groupRef set",
582+
groupRef: &MCPGroupRef{Name: "from-spec"},
583+
cfgGroup: "",
584+
want: "from-spec",
585+
},
586+
{
587+
name: "neither set returns empty",
588+
groupRef: nil,
589+
cfgGroup: "",
590+
want: "",
591+
},
592+
}
593+
for _, tt := range tests {
594+
t.Run(tt.name, func(t *testing.T) {
595+
t.Parallel()
596+
vmcp := &VirtualMCPServer{
597+
Spec: VirtualMCPServerSpec{
598+
GroupRef: tt.groupRef,
599+
Config: config.Config{Group: tt.cfgGroup},
600+
},
601+
}
602+
assert.Equal(t, tt.want, vmcp.ResolveGroupName())
603+
})
604+
}
605+
}

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

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/controllers/mcpgroup_controller.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,8 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c
431431
ctxLogger.Error(nil, "Object is not an MCPServer", "object", obj.GetName())
432432
return []ctrl.Request{}
433433
}
434-
if mcpServer.Spec.GroupRef == "" {
434+
groupName := mcpServer.Spec.GroupRef.GetName()
435+
if groupName == "" {
435436
// No MCPGroup reference, nothing to do
436437
return []ctrl.Request{}
437438
}
@@ -444,10 +445,10 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c
444445
"mcpserver",
445446
obj.GetName(),
446447
"groupRef",
447-
mcpServer.Spec.GroupRef)
448+
groupName)
448449
group := &mcpv1alpha1.MCPGroup{}
449-
if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServer.Spec.GroupRef}, group); err != nil {
450-
ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", mcpServer.Spec.GroupRef)
450+
if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}, group); err != nil {
451+
ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", groupName)
451452
return []ctrl.Request{}
452453
}
453454
return []ctrl.Request{
@@ -469,7 +470,8 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context,
469470
ctxLogger.Error(nil, "Object is not an MCPRemoteProxy", "object", obj.GetName())
470471
return []ctrl.Request{}
471472
}
472-
if mcpRemoteProxy.Spec.GroupRef == "" {
473+
groupName := mcpRemoteProxy.Spec.GroupRef.GetName()
474+
if groupName == "" {
473475
// No MCPGroup reference, nothing to do
474476
return []ctrl.Request{}
475477
}
@@ -482,12 +484,12 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context,
482484
"mcpremoteproxy",
483485
obj.GetName(),
484486
"groupRef",
485-
mcpRemoteProxy.Spec.GroupRef)
487+
groupName)
486488
group := &mcpv1alpha1.MCPGroup{}
487-
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpRemoteProxy.Spec.GroupRef}
489+
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}
488490
if err := r.Get(ctx, groupKey, group); err != nil {
489491
ctxLogger.Error(err, "Failed to get MCPGroup for MCPRemoteProxy",
490-
"namespace", obj.GetNamespace(), "name", mcpRemoteProxy.Spec.GroupRef)
492+
"namespace", obj.GetNamespace(), "name", groupName)
491493
return []ctrl.Request{}
492494
}
493495
return []ctrl.Request{
@@ -508,20 +510,21 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServerEntry(ctx context.Context,
508510
ctxLogger.Error(nil, "Object is not an MCPServerEntry", "object", obj.GetName())
509511
return []ctrl.Request{}
510512
}
511-
if mcpServerEntry.Spec.GroupRef == "" {
513+
groupName := mcpServerEntry.Spec.GroupRef.GetName()
514+
if groupName == "" {
512515
return []ctrl.Request{}
513516
}
514517

515518
ctxLogger.Info(
516519
"Finding MCPGroup for MCPServerEntry",
517520
"namespace", obj.GetNamespace(),
518521
"mcpserverentry", obj.GetName(),
519-
"groupRef", mcpServerEntry.Spec.GroupRef)
522+
"groupRef", groupName)
520523
group := &mcpv1alpha1.MCPGroup{}
521-
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServerEntry.Spec.GroupRef}
524+
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}
522525
if err := r.Get(ctx, groupKey, group); err != nil {
523526
ctxLogger.Error(err, "Failed to get MCPGroup for MCPServerEntry",
524-
"namespace", obj.GetNamespace(), "name", mcpServerEntry.Spec.GroupRef)
527+
"namespace", obj.GetNamespace(), "name", groupName)
525528
return []ctrl.Request{}
526529
}
527530
return []ctrl.Request{

0 commit comments

Comments
 (0)