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
6 changes: 3 additions & 3 deletions cmd/thv-operator/api/v1alpha1/mcpremoteproxy_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ type MCPRemoteProxySpec struct {
// +optional
ResourceOverrides *ResourceOverrides `json:"resourceOverrides,omitempty"`

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

// SessionAffinity controls whether the Service routes repeated client connections to the same pod.
// MCP protocols (SSE, streamable-http) are stateful, so ClientIP is the default.
Expand Down
23 changes: 20 additions & 3 deletions cmd/thv-operator/api/v1alpha1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,10 @@ type MCPServerSpec struct {
// +optional
EndpointPrefix string `json:"endpointPrefix,omitempty"`

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

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

// MCPGroupRef defines a reference to an MCPGroup resource.
// The referenced MCPGroup must be in the same namespace.
type MCPGroupRef struct {
// Name is the name of the MCPGroup resource in the same namespace
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}

// GetName returns the name, or empty string if the receiver is nil.
func (r *MCPGroupRef) GetName() string {
if r == nil {
return ""
}
return r.Name
}

// InlineAuthzConfig contains direct authorization configuration
type InlineAuthzConfig struct {
// Policies is a list of Cedar policy strings
Expand Down
7 changes: 3 additions & 4 deletions cmd/thv-operator/api/v1alpha1/mcpserverentry_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,10 @@ type MCPServerEntrySpec struct {
// +kubebuilder:validation:Enum=sse;streamable-http
Transport string `json:"transport"`

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

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

// MCPServerEntry is the Schema for the mcpserverentries API.
Expand Down
25 changes: 19 additions & 6 deletions cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@ type VirtualMCPServerSpec struct {
// +kubebuilder:validation:Type=object
PodTemplateSpec *runtime.RawExtension `json:"podTemplateSpec,omitempty"`

// Config is the Virtual MCP server configuration
// The only field currently required within config is `config.groupRef`.
// GroupRef references an existing MCPGroup that defines backend workloads.
// GroupRef references the MCPGroup that defines backend workloads.
// The referenced MCPGroup must exist in the same namespace.
// This field takes precedence over config.groupRef and should be preferred.
// +optional
GroupRef *MCPGroupRef `json:"groupRef,omitempty"`

// Config is the Virtual MCP server configuration.
// The audit config from here is also supported, but not required.
// Note: config.groupRef is deprecated in favor of spec.groupRef.
// Note: config.telemetry is deprecated — use spec.telemetryConfigRef to reference
// a shared MCPTelemetryConfig resource instead.
// +optional
Expand Down Expand Up @@ -447,13 +451,22 @@ func (*VirtualMCPServer) GetProxyPort() int32 {
return 4483
}

// ResolveGroupName returns the effective group name, preferring spec.groupRef
// over the deprecated config.groupRef string.
func (r *VirtualMCPServer) ResolveGroupName() string {
Comment thread
ChrisJBurns marked this conversation as resolved.
if name := r.Spec.GroupRef.GetName(); name != "" {
return name
}
return r.Spec.Config.Group
}

// Validate performs validation for VirtualMCPServer
// This method is called by the controller during reconciliation
func (r *VirtualMCPServer) Validate() error {
// Validate Group is set (required field)
// Validate Group is set — prefer spec.groupRef, fall back to config.groupRef
// Note: CEL cannot validate embedded types from other packages
if r.Spec.Config.Group == "" {
return fmt.Errorf("spec.config.groupRef is required")
if r.Spec.GroupRef.GetName() == "" && r.Spec.Config.Group == "" {
return fmt.Errorf("either spec.groupRef.name or config.groupRef must be set")
}

// Note: IncomingAuth validation is handled by kubebuilder markers and CEL rules
Expand Down
68 changes: 68 additions & 0 deletions cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -535,3 +535,71 @@ func TestVirtualMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) {
})
}
}

func TestMCPGroupRef_GetName(t *testing.T) {
t.Parallel()

tests := []struct {
name string
ref *MCPGroupRef
want string
}{
{name: "nil receiver", ref: nil, want: ""},
{name: "empty name", ref: &MCPGroupRef{Name: ""}, want: ""},
{name: "non-empty name", ref: &MCPGroupRef{Name: "my-group"}, want: "my-group"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, tt.ref.GetName())
})
}
}

func TestVirtualMCPServer_ResolveGroupName(t *testing.T) {
t.Parallel()

tests := []struct {
name string
groupRef *MCPGroupRef
cfgGroup string
want string
}{
{
name: "spec.groupRef takes precedence over config.groupRef",
groupRef: &MCPGroupRef{Name: "from-spec"},
cfgGroup: "from-config",
want: "from-spec",
},
{
name: "falls back to config.groupRef when spec.groupRef is nil",
groupRef: nil,
cfgGroup: "from-config",
want: "from-config",
},
{
name: "only spec.groupRef set",
groupRef: &MCPGroupRef{Name: "from-spec"},
cfgGroup: "",
want: "from-spec",
},
{
name: "neither set returns empty",
groupRef: nil,
cfgGroup: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
vmcp := &VirtualMCPServer{
Spec: VirtualMCPServerSpec{
GroupRef: tt.groupRef,
Config: config.Config{Group: tt.cfgGroup},
},
}
assert.Equal(t, tt.want, vmcp.ResolveGroupName())
})
}
}
35 changes: 35 additions & 0 deletions cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

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

27 changes: 15 additions & 12 deletions cmd/thv-operator/controllers/mcpgroup_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,8 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c
ctxLogger.Error(nil, "Object is not an MCPServer", "object", obj.GetName())
return []ctrl.Request{}
}
if mcpServer.Spec.GroupRef == "" {
groupName := mcpServer.Spec.GroupRef.GetName()
if groupName == "" {
// No MCPGroup reference, nothing to do
return []ctrl.Request{}
}
Expand All @@ -444,10 +445,10 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj c
"mcpserver",
obj.GetName(),
"groupRef",
mcpServer.Spec.GroupRef)
groupName)
group := &mcpv1alpha1.MCPGroup{}
if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServer.Spec.GroupRef}, group); err != nil {
ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", mcpServer.Spec.GroupRef)
if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}, group); err != nil {
ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", groupName)
return []ctrl.Request{}
}
return []ctrl.Request{
Expand All @@ -469,7 +470,8 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context,
ctxLogger.Error(nil, "Object is not an MCPRemoteProxy", "object", obj.GetName())
return []ctrl.Request{}
}
if mcpRemoteProxy.Spec.GroupRef == "" {
groupName := mcpRemoteProxy.Spec.GroupRef.GetName()
if groupName == "" {
// No MCPGroup reference, nothing to do
return []ctrl.Request{}
}
Expand All @@ -482,12 +484,12 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPRemoteProxy(ctx context.Context,
"mcpremoteproxy",
obj.GetName(),
"groupRef",
mcpRemoteProxy.Spec.GroupRef)
groupName)
group := &mcpv1alpha1.MCPGroup{}
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpRemoteProxy.Spec.GroupRef}
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}
if err := r.Get(ctx, groupKey, group); err != nil {
ctxLogger.Error(err, "Failed to get MCPGroup for MCPRemoteProxy",
"namespace", obj.GetNamespace(), "name", mcpRemoteProxy.Spec.GroupRef)
"namespace", obj.GetNamespace(), "name", groupName)
return []ctrl.Request{}
}
return []ctrl.Request{
Expand All @@ -508,20 +510,21 @@ func (r *MCPGroupReconciler) findMCPGroupForMCPServerEntry(ctx context.Context,
ctxLogger.Error(nil, "Object is not an MCPServerEntry", "object", obj.GetName())
return []ctrl.Request{}
}
if mcpServerEntry.Spec.GroupRef == "" {
groupName := mcpServerEntry.Spec.GroupRef.GetName()
if groupName == "" {
return []ctrl.Request{}
}

ctxLogger.Info(
"Finding MCPGroup for MCPServerEntry",
"namespace", obj.GetNamespace(),
"mcpserverentry", obj.GetName(),
"groupRef", mcpServerEntry.Spec.GroupRef)
"groupRef", groupName)
group := &mcpv1alpha1.MCPGroup{}
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServerEntry.Spec.GroupRef}
groupKey := types.NamespacedName{Namespace: obj.GetNamespace(), Name: groupName}
if err := r.Get(ctx, groupKey, group); err != nil {
ctxLogger.Error(err, "Failed to get MCPGroup for MCPServerEntry",
"namespace", obj.GetNamespace(), "name", mcpServerEntry.Spec.GroupRef)
"namespace", obj.GetNamespace(), "name", groupName)
return []ctrl.Request{}
}
return []ctrl.Request{
Expand Down
Loading
Loading