Skip to content

Commit cdf8a49

Browse files
yroblataskbot
andauthored
feat: Implement VirtualMCPServer Kubernetes controller (#2448)
* feat: Implement VirtualMCPServer Kubernetes controller Implements the controller for VirtualMCPServer custom resource that orchestrates Virtual MCP Servers in Kubernetes, including backend discovery, configuration management, and resource orchestration. Closes: #2446 * fix lint * fixes from claude review * fix incorrect manifest * fix controller deployment * remove all discovery functionality * changes from review * fixes from review --------- Co-authored-by: taskbot <taskbot@users.noreply.github.com>
1 parent eceb293 commit cdf8a49

Some content is hidden

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

42 files changed

+5742
-613
lines changed

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

Lines changed: 5 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ type GroupRef struct {
6262

6363
// IncomingAuthConfig configures authentication for clients connecting to the Virtual MCP server
6464
type IncomingAuthConfig struct {
65+
// Type defines the authentication type: anonymous, local, or oidc
66+
// +kubebuilder:validation:Enum=anonymous;local;oidc
67+
// +optional
68+
Type string `json:"type,omitempty"`
69+
6570
// OIDCConfig defines OIDC authentication configuration
6671
// Reuses MCPServer OIDC patterns
6772
// +optional
@@ -426,14 +431,6 @@ type VirtualMCPServerStatus struct {
426431
// +optional
427432
Conditions []metav1.Condition `json:"conditions,omitempty"`
428433

429-
// DiscoveredBackends lists discovered backend configurations when source=discovered
430-
// +optional
431-
DiscoveredBackends []DiscoveredBackend `json:"discoveredBackends,omitempty"`
432-
433-
// Capabilities summarizes aggregated capabilities from all backends
434-
// +optional
435-
Capabilities *CapabilitiesSummary `json:"capabilities,omitempty"`
436-
437434
// ObservedGeneration is the most recent generation observed for this VirtualMCPServer
438435
// +optional
439436
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
@@ -452,54 +449,6 @@ type VirtualMCPServerStatus struct {
452449
URL string `json:"url,omitempty"`
453450
}
454451

455-
// DiscoveredBackend represents a discovered backend MCPServer
456-
type DiscoveredBackend struct {
457-
// Name is the name of the backend MCPServer
458-
// +kubebuilder:validation:Required
459-
Name string `json:"name"`
460-
461-
// AuthConfigRef is the name of the discovered MCPExternalAuthConfig
462-
// Empty if backend has no external auth config
463-
// +optional
464-
AuthConfigRef string `json:"authConfigRef,omitempty"`
465-
466-
// AuthType is the type of authentication configured
467-
// +optional
468-
AuthType string `json:"authType,omitempty"`
469-
470-
// Status is the current status of the backend
471-
// +kubebuilder:validation:Enum=ready;degraded;unavailable
472-
// +optional
473-
Status string `json:"status,omitempty"`
474-
475-
// LastHealthCheck is the timestamp of the last health check
476-
// +optional
477-
LastHealthCheck *metav1.Time `json:"lastHealthCheck,omitempty"`
478-
479-
// URL is the URL of the backend MCPServer
480-
// +optional
481-
URL string `json:"url,omitempty"`
482-
}
483-
484-
// CapabilitiesSummary summarizes aggregated capabilities
485-
type CapabilitiesSummary struct {
486-
// ToolCount is the total number of tools exposed
487-
// +optional
488-
ToolCount int `json:"toolCount,omitempty"`
489-
490-
// ResourceCount is the total number of resources exposed
491-
// +optional
492-
ResourceCount int `json:"resourceCount,omitempty"`
493-
494-
// PromptCount is the total number of prompts exposed
495-
// +optional
496-
PromptCount int `json:"promptCount,omitempty"`
497-
498-
// CompositeToolCount is the number of composite tools defined
499-
// +optional
500-
CompositeToolCount int `json:"compositeToolCount,omitempty"`
501-
}
502-
503452
// VirtualMCPServerPhase represents the lifecycle phase of a VirtualMCPServer
504453
// +kubebuilder:validation:Enum=Pending;Ready;Degraded;Failed
505454
type VirtualMCPServerPhase string
@@ -524,36 +473,18 @@ const (
524473
// ConditionTypeVirtualMCPServerReady indicates whether the VirtualMCPServer is ready
525474
ConditionTypeVirtualMCPServerReady = "Ready"
526475

527-
// ConditionTypeBackendsDiscovered indicates whether backends have been discovered
528-
ConditionTypeBackendsDiscovered = "BackendsDiscovered"
529-
530476
// ConditionTypeVirtualMCPServerGroupRefValidated indicates whether the GroupRef is valid
531477
ConditionTypeVirtualMCPServerGroupRefValidated = "GroupRefValidated"
532478
)
533479

534480
// Condition reasons for VirtualMCPServer
535481
const (
536-
// ConditionReasonAllBackendsReady indicates all backends are ready
537-
ConditionReasonAllBackendsReady = "AllBackendsReady"
538-
539-
// ConditionReasonSomeBackendsUnavailable indicates some backends are unavailable
540-
ConditionReasonSomeBackendsUnavailable = "SomeBackendsUnavailable"
541-
542-
// ConditionReasonNoBackends indicates no backends were discovered
543-
ConditionReasonNoBackends = "NoBackends"
544-
545482
// ConditionReasonIncomingAuthValid indicates incoming auth is valid
546483
ConditionReasonIncomingAuthValid = "IncomingAuthValid"
547484

548485
// ConditionReasonIncomingAuthInvalid indicates incoming auth is invalid
549486
ConditionReasonIncomingAuthInvalid = "IncomingAuthInvalid"
550487

551-
// ConditionReasonDiscoveryComplete indicates backend discovery is complete
552-
ConditionReasonDiscoveryComplete = "DiscoveryComplete"
553-
554-
// ConditionReasonDiscoveryFailed indicates backend discovery failed
555-
ConditionReasonDiscoveryFailed = "DiscoveryFailed"
556-
557488
// ConditionReasonGroupRefValid indicates the GroupRef is valid
558489
ConditionReasonVirtualMCPServerGroupRefValid = "GroupRefValid"
559490

@@ -604,8 +535,6 @@ const (
604535
//+kubebuilder:subresource:status
605536
//+kubebuilder:resource:shortName=vmcp;virtualmcp
606537
//+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="The phase of the VirtualMCPServer"
607-
//+kubebuilder:printcolumn:name="Tools",type="integer",JSONPath=".status.capabilities.toolCount",description="Total tools"
608-
//+kubebuilder:printcolumn:name="Backends",type="integer",JSONPath=".status.discoveredBackends[*]",description="Backends"
609538
//+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url",description="Virtual MCP server URL"
610539
//+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age"
611540
//+kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"

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

Lines changed: 2 additions & 208 deletions
Original file line numberDiff line numberDiff line change
@@ -92,62 +92,22 @@ func TestVirtualMCPServerConditions(t *testing.T) {
9292
{
9393
Type: ConditionTypeVirtualMCPServerReady,
9494
Status: metav1.ConditionTrue,
95-
Reason: ConditionReasonAllBackendsReady,
95+
Reason: "DeploymentReady",
9696
},
9797
{
9898
Type: ConditionTypeAuthConfigured,
9999
Status: metav1.ConditionTrue,
100100
Reason: ConditionReasonIncomingAuthValid,
101101
},
102-
{
103-
Type: ConditionTypeBackendsDiscovered,
104-
Status: metav1.ConditionTrue,
105-
Reason: ConditionReasonDiscoveryComplete,
106-
},
107102
},
108103
validate: func(t *testing.T, vmcp *VirtualMCPServer) {
109104
t.Helper()
110-
assert.Len(t, vmcp.Status.Conditions, 3)
105+
assert.Len(t, vmcp.Status.Conditions, 2)
111106
for _, cond := range vmcp.Status.Conditions {
112107
assert.Equal(t, metav1.ConditionTrue, cond.Status)
113108
}
114109
},
115110
},
116-
{
117-
name: "ready_false_with_backend_issues",
118-
conditions: []metav1.Condition{
119-
{
120-
Type: ConditionTypeVirtualMCPServerReady,
121-
Status: metav1.ConditionFalse,
122-
Reason: ConditionReasonSomeBackendsUnavailable,
123-
Message: "2 out of 5 backends unavailable",
124-
},
125-
},
126-
validate: func(t *testing.T, vmcp *VirtualMCPServer) {
127-
t.Helper()
128-
assert.Len(t, vmcp.Status.Conditions, 1)
129-
cond := vmcp.Status.Conditions[0]
130-
assert.Equal(t, metav1.ConditionFalse, cond.Status)
131-
assert.Contains(t, cond.Message, "backends unavailable")
132-
},
133-
},
134-
{
135-
name: "discovery_failed",
136-
conditions: []metav1.Condition{
137-
{
138-
Type: ConditionTypeBackendsDiscovered,
139-
Status: metav1.ConditionFalse,
140-
Reason: ConditionReasonDiscoveryFailed,
141-
Message: "Failed to discover backends from group",
142-
},
143-
},
144-
validate: func(t *testing.T, vmcp *VirtualMCPServer) {
145-
t.Helper()
146-
cond := vmcp.Status.Conditions[0]
147-
assert.Equal(t, ConditionTypeBackendsDiscovered, cond.Type)
148-
assert.Equal(t, metav1.ConditionFalse, cond.Status)
149-
},
150-
},
151111
}
152112

153113
for _, tt := range tests {
@@ -169,172 +129,6 @@ func TestVirtualMCPServerConditions(t *testing.T) {
169129
}
170130
}
171131

172-
func TestDiscoveredBackendsStatus(t *testing.T) {
173-
t.Parallel()
174-
175-
tests := []struct {
176-
name string
177-
discoveredBackends []DiscoveredBackend
178-
expectedCount int
179-
validate func(*testing.T, []DiscoveredBackend)
180-
}{
181-
{
182-
name: "multiple_backends_all_ready",
183-
discoveredBackends: []DiscoveredBackend{
184-
{
185-
Name: "github",
186-
AuthConfigRef: "github-token-exchange",
187-
AuthType: "token_exchange",
188-
Status: "ready",
189-
URL: "http://github-mcp.default.svc:8080",
190-
},
191-
{
192-
Name: "jira",
193-
AuthConfigRef: "jira-token-exchange",
194-
AuthType: "token_exchange",
195-
Status: "ready",
196-
URL: "http://jira-mcp.default.svc:8080",
197-
},
198-
{
199-
Name: "slack",
200-
AuthType: "service_account",
201-
Status: "ready",
202-
URL: "http://slack-mcp.default.svc:8080",
203-
},
204-
},
205-
expectedCount: 3,
206-
validate: func(t *testing.T, backends []DiscoveredBackend) {
207-
t.Helper()
208-
readyCount := 0
209-
for _, b := range backends {
210-
if b.Status == "ready" {
211-
readyCount++
212-
}
213-
}
214-
assert.Equal(t, 3, readyCount, "All backends should be ready")
215-
},
216-
},
217-
{
218-
name: "mixed_backend_status",
219-
discoveredBackends: []DiscoveredBackend{
220-
{
221-
Name: "github",
222-
AuthConfigRef: "github-token-exchange",
223-
Status: "ready",
224-
},
225-
{
226-
Name: "jira",
227-
AuthConfigRef: "jira-token-exchange",
228-
Status: "degraded",
229-
},
230-
{
231-
Name: "slack",
232-
Status: "unavailable",
233-
},
234-
},
235-
expectedCount: 3,
236-
validate: func(t *testing.T, backends []DiscoveredBackend) {
237-
t.Helper()
238-
statusCounts := make(map[string]int)
239-
for _, b := range backends {
240-
statusCounts[b.Status]++
241-
}
242-
assert.Equal(t, 1, statusCounts["ready"])
243-
assert.Equal(t, 1, statusCounts["degraded"])
244-
assert.Equal(t, 1, statusCounts["unavailable"])
245-
},
246-
},
247-
{
248-
name: "backend_with_no_auth",
249-
discoveredBackends: []DiscoveredBackend{
250-
{
251-
Name: "internal-api",
252-
AuthConfigRef: "", // No auth config
253-
AuthType: "pass_through",
254-
Status: "ready",
255-
},
256-
},
257-
expectedCount: 1,
258-
validate: func(t *testing.T, backends []DiscoveredBackend) {
259-
t.Helper()
260-
assert.Empty(t, backends[0].AuthConfigRef)
261-
assert.Equal(t, "pass_through", backends[0].AuthType)
262-
},
263-
},
264-
}
265-
266-
for _, tt := range tests {
267-
t.Run(tt.name, func(t *testing.T) {
268-
t.Parallel()
269-
270-
vmcp := &VirtualMCPServer{
271-
Status: VirtualMCPServerStatus{
272-
DiscoveredBackends: tt.discoveredBackends,
273-
},
274-
}
275-
276-
assert.Len(t, vmcp.Status.DiscoveredBackends, tt.expectedCount)
277-
tt.validate(t, vmcp.Status.DiscoveredBackends)
278-
})
279-
}
280-
}
281-
282-
func TestCapabilitiesSummary(t *testing.T) {
283-
t.Parallel()
284-
285-
tests := []struct {
286-
name string
287-
capabilities *CapabilitiesSummary
288-
validate func(*testing.T, *CapabilitiesSummary)
289-
}{
290-
{
291-
name: "full_capabilities",
292-
capabilities: &CapabilitiesSummary{
293-
ToolCount: 25,
294-
ResourceCount: 10,
295-
PromptCount: 5,
296-
CompositeToolCount: 3,
297-
},
298-
validate: func(t *testing.T, caps *CapabilitiesSummary) {
299-
t.Helper()
300-
assert.Equal(t, 25, caps.ToolCount)
301-
assert.Equal(t, 10, caps.ResourceCount)
302-
assert.Equal(t, 5, caps.PromptCount)
303-
assert.Equal(t, 3, caps.CompositeToolCount)
304-
},
305-
},
306-
{
307-
name: "only_tools_no_resources",
308-
capabilities: &CapabilitiesSummary{
309-
ToolCount: 15,
310-
ResourceCount: 0,
311-
PromptCount: 0,
312-
CompositeToolCount: 1,
313-
},
314-
validate: func(t *testing.T, caps *CapabilitiesSummary) {
315-
t.Helper()
316-
assert.Greater(t, caps.ToolCount, 0)
317-
assert.Equal(t, 0, caps.ResourceCount)
318-
assert.Equal(t, 0, caps.PromptCount)
319-
},
320-
},
321-
}
322-
323-
for _, tt := range tests {
324-
t.Run(tt.name, func(t *testing.T) {
325-
t.Parallel()
326-
327-
vmcp := &VirtualMCPServer{
328-
Status: VirtualMCPServerStatus{
329-
Capabilities: tt.capabilities,
330-
},
331-
}
332-
333-
tt.validate(t, vmcp.Status.Capabilities)
334-
})
335-
}
336-
}
337-
338132
func TestVirtualMCPServerDefaultValues(t *testing.T) {
339133
t.Parallel()
340134

0 commit comments

Comments
 (0)