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
30 changes: 0 additions & 30 deletions cmd/thv-operator/api/v1beta1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
package v1beta1

import (
"encoding/json"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -319,14 +317,7 @@ type MCPServerSpec struct {

// GroupRef references the MCPGroup this server belongs to.
// The referenced MCPGroup must be in the same namespace.
// Accepts {name: <group>} or, for objects created before v0.20, a plain
// group name string. Both forms decode to the same MCPGroupRef. The schema
// is untyped to admit both forms, so CEL cannot validate it; an invalid
// value decodes to an empty name and is reported by the GroupRefValidated
// condition.
// +optional
// +kubebuilder:validation:Schemaless
// +kubebuilder:pruning:PreserveUnknownFields
GroupRef *MCPGroupRef `json:"groupRef,omitempty"`

// SessionAffinity controls whether the Service routes repeated client connections to the same pod.
Expand Down Expand Up @@ -750,27 +741,6 @@ type MCPGroupRef struct {
Name string `json:"name"`
}

// UnmarshalJSON accepts both the object form {"name": "<group>"} and the
// legacy plain string form "<group>" used by MCPServer before v0.20.
// Any other shape decodes to an empty name instead of failing, so a single
// malformed object cannot break list/watch decoding in the operator cache.
func (r *MCPGroupRef) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err == nil {
r.Name = name
return nil
}
var obj struct {
Name any `json:"name"`
}
if err := json.Unmarshal(data, &obj); err != nil {
r.Name = ""
return nil
}
r.Name, _ = obj.Name.(string)
return nil
}

// GetName returns the name, or empty string if the receiver is nil.
func (r *MCPGroupRef) GetName() string {
if r == nil {
Expand Down
35 changes: 0 additions & 35 deletions cmd/thv-operator/api/v1beta1/mcpserver_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,38 +180,3 @@ func TestMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) {
})
}
}

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

tests := []struct {
name string
input string
want *MCPGroupRef
}{
{name: "object form", input: `{"groupRef":{"name":"platform"}}`, want: &MCPGroupRef{Name: "platform"}},
{name: "legacy string form", input: `{"groupRef":"platform"}`, want: &MCPGroupRef{Name: "platform"}},
{name: "absent", input: `{}`, want: nil},
{name: "number decodes to empty name", input: `{"groupRef":42}`, want: &MCPGroupRef{}},
{name: "array decodes to empty name", input: `{"groupRef":["platform"]}`, want: &MCPGroupRef{}},
{name: "non-string name decodes to empty name", input: `{"groupRef":{"name":7}}`, want: &MCPGroupRef{}},
}

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

var spec MCPServerSpec
require.NoError(t, json.Unmarshal([]byte(tt.input), &spec))
assert.Equal(t, tt.want, spec.GroupRef)
})
}
}

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

data, err := json.Marshal(MCPServerSpec{Image: "img", GroupRef: &MCPGroupRef{Name: "platform"}})
require.NoError(t, err)
assert.Contains(t, string(data), `"groupRef":{"name":"platform"}`)
}
6 changes: 2 additions & 4 deletions cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,8 @@ type VirtualMCPServerSpec struct {

// GroupRef references the MCPGroup that defines backend workloads.
// The referenced MCPGroup must exist in the same namespace.
// Optional in the schema so objects created before groupRef was introduced
// still validate; the controller rejects a VirtualMCPServer without it.
// +optional
GroupRef *MCPGroupRef `json:"groupRef,omitempty"`
// +kubebuilder:validation:Required
GroupRef *MCPGroupRef `json:"groupRef"`

// Config is the Virtual MCP server configuration.
// The audit config from here is also supported, but not required.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,23 +122,15 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid
})
})

Context("groupRef transition shim", func() {
createRaw := func(name string, groupRef any) error {
Context("groupRef schema", func() {
It("should reject the legacy string form", func() {
obj := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "toolhive.stacklok.dev/v1beta1",
"kind": "MCPServer",
"metadata": map[string]any{"name": name, "namespace": "default"},
"spec": map[string]any{"image": "example/mcp-server:latest", "groupRef": groupRef},
"metadata": map[string]any{"name": "groupref-string", "namespace": "default"},
"spec": map[string]any{"image": "example/mcp-server:latest", "groupRef": "platform"},
}}
return k8sClient.Create(ctx, obj)
}

It("should accept and decode the legacy string form", func() {
Expect(createRaw("groupref-string", "platform")).To(Succeed())

server := &mcpv1beta1.MCPServer{}
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "groupref-string", Namespace: "default"}, server)).To(Succeed())
Expect(server.Spec.GroupRef.GetName()).To(Equal("platform"))
Expect(k8sClient.Create(ctx, obj)).NotTo(Succeed())
})

It("should accept the object form", func() {
Expand All @@ -150,26 +142,6 @@ var _ = Describe("CEL Validation for AuthzConfigRef", Label("k8s", "cel", "valid
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "groupref-object", Namespace: "default"}, fetched)).To(Succeed())
Expect(fetched.Spec.GroupRef.GetName()).To(Equal("platform"))
})

It("should accept the legacy string form through v1alpha1", func() {
obj := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "toolhive.stacklok.dev/v1alpha1",
"kind": "MCPServer",
"metadata": map[string]any{"name": "groupref-string-v1alpha1", "namespace": "default"},
"spec": map[string]any{"image": "example/mcp-server:latest", "groupRef": "platform"},
}}
Expect(k8sClient.Create(ctx, obj)).To(Succeed())
})

It("should decode an unexpected groupRef shape to an empty name without failing", func() {
Expect(createRaw("groupref-number", int64(42))).To(Succeed())

list := &mcpv1beta1.MCPServerList{}
Expect(k8sClient.List(ctx, list)).To(Succeed())
server := &mcpv1beta1.MCPServer{}
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "groupref-number", Namespace: "default"}, server)).To(Succeed())
Expect(server.Spec.GroupRef.GetName()).To(BeEmpty())
})
})

Context("SecretRef schema validation", func() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,15 @@ spec:
description: |-
GroupRef references the MCPGroup this server belongs to.
The referenced MCPGroup must be in the same namespace.
Accepts {name: <group>} or, for objects created before v0.20, a plain
group name string. Both forms decode to the same MCPGroupRef. The schema
is untyped to admit both forms, so CEL cannot validate it; an invalid
value decodes to an empty name and is reported by the GroupRefValidated
condition.
x-kubernetes-preserve-unknown-fields: true
properties:
name:
description: Name is the name of the MCPGroup resource in the
same namespace
minLength: 1
type: string
required:
- name
type: object
image:
description: Image is the container image for the MCP server
type: string
Expand Down Expand Up @@ -1089,12 +1092,15 @@ spec:
description: |-
GroupRef references the MCPGroup this server belongs to.
The referenced MCPGroup must be in the same namespace.
Accepts {name: <group>} or, for objects created before v0.20, a plain
group name string. Both forms decode to the same MCPGroupRef. The schema
is untyped to admit both forms, so CEL cannot validate it; an invalid
value decodes to an empty name and is reported by the GroupRefValidated
condition.
x-kubernetes-preserve-unknown-fields: true
properties:
name:
description: Name is the name of the MCPGroup resource in the
same namespace
minLength: 1
type: string
required:
- name
type: object
image:
description: Image is the container image for the MCP server
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1844,8 +1844,6 @@ spec:
description: |-
GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
Optional in the schema so objects created before groupRef was introduced
still validate; the controller rejects a VirtualMCPServer without it.
properties:
name:
description: Name is the name of the MCPGroup resource in the
Expand Down Expand Up @@ -2179,6 +2177,7 @@ spec:
- name
type: object
required:
- groupRef
- incomingAuth
type: object
status:
Expand Down Expand Up @@ -4174,8 +4173,6 @@ spec:
description: |-
GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
Optional in the schema so objects created before groupRef was introduced
still validate; the controller rejects a VirtualMCPServer without it.
properties:
name:
description: Name is the name of the MCPGroup resource in the
Expand Down Expand Up @@ -4509,6 +4506,7 @@ spec:
- name
type: object
required:
- groupRef
- incomingAuth
type: object
status:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,15 @@ spec:
description: |-
GroupRef references the MCPGroup this server belongs to.
The referenced MCPGroup must be in the same namespace.
Accepts {name: <group>} or, for objects created before v0.20, a plain
group name string. Both forms decode to the same MCPGroupRef. The schema
is untyped to admit both forms, so CEL cannot validate it; an invalid
value decodes to an empty name and is reported by the GroupRefValidated
condition.
x-kubernetes-preserve-unknown-fields: true
properties:
name:
description: Name is the name of the MCPGroup resource in the
same namespace
minLength: 1
type: string
required:
- name
type: object
image:
description: Image is the container image for the MCP server
type: string
Expand Down Expand Up @@ -1092,12 +1095,15 @@ spec:
description: |-
GroupRef references the MCPGroup this server belongs to.
The referenced MCPGroup must be in the same namespace.
Accepts {name: <group>} or, for objects created before v0.20, a plain
group name string. Both forms decode to the same MCPGroupRef. The schema
is untyped to admit both forms, so CEL cannot validate it; an invalid
value decodes to an empty name and is reported by the GroupRefValidated
condition.
x-kubernetes-preserve-unknown-fields: true
properties:
name:
description: Name is the name of the MCPGroup resource in the
same namespace
minLength: 1
type: string
required:
- name
type: object
image:
description: Image is the container image for the MCP server
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1847,8 +1847,6 @@ spec:
description: |-
GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
Optional in the schema so objects created before groupRef was introduced
still validate; the controller rejects a VirtualMCPServer without it.
properties:
name:
description: Name is the name of the MCPGroup resource in the
Expand Down Expand Up @@ -2182,6 +2180,7 @@ spec:
- name
type: object
required:
- groupRef
- incomingAuth
type: object
status:
Expand Down Expand Up @@ -4177,8 +4176,6 @@ spec:
description: |-
GroupRef references the MCPGroup that defines backend workloads.
The referenced MCPGroup must exist in the same namespace.
Optional in the schema so objects created before groupRef was introduced
still validate; the controller rejects a VirtualMCPServer without it.
properties:
name:
description: Name is the name of the MCPGroup resource in the
Expand Down Expand Up @@ -4512,6 +4509,7 @@ spec:
- name
type: object
required:
- groupRef
- incomingAuth
type: object
status:
Expand Down
Loading