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
22 changes: 22 additions & 0 deletions internal/controller/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ const (
eventActionConfigurationValidation = "ConfigurationValidation"
// eventActionConfigurationAccepted is the reporting action when Accepted becomes True.
eventActionConfigurationAccepted = "ConfigurationAccepted"
// eventActionServerReady is the reporting action when Ready becomes True with reason Available.
eventActionServerReady = "ServerReady"

// requeueDelayMCPHandshake is the initial delay before requeuing when an MCP handshake fails.
requeueDelayMCPHandshake = 10 * time.Second
Expand Down Expand Up @@ -204,6 +206,7 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
logger.Info("Reconciling MCPServer", "name", mcpServer.Name, "namespace", mcpServer.Namespace)

pendingAcceptedEvent := !acceptedConditionIsTrue(mcpServer.Status.Conditions)
pendingServerReadyEvent := !readyConditionIsAvailable(mcpServer.Status.Conditions)

// Validate configuration
validationStart := time.Now()
Expand Down Expand Up @@ -345,6 +348,13 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
var serverInfo *mcpv1alpha1.MCPServerInfo
readyCondition, serverInfo = r.reconcileHandshake(ctx, mcpServer, mcpURL, readyCondition)

// Normal Event once per Ready transition to Available after a successful handshake.
if pendingServerReadyEvent &&
readyCondition.Status == metav1.ConditionTrue &&
readyCondition.Reason == ReasonAvailable {
r.emitServerReady(mcpServer)
}

var handshakeRetryCount int32
if readyCondition.Reason == ReasonMCPEndpointUnavailable {
if mcpServer.Status.ObservedGeneration == mcpServer.Generation {
Expand Down Expand Up @@ -1241,6 +1251,11 @@ func acceptedConditionIsTrue(conditions []metav1.Condition) bool {
return c != nil && c.Status == metav1.ConditionTrue
}

func readyConditionIsAvailable(conditions []metav1.Condition) bool {
c := meta.FindStatusCondition(conditions, ConditionTypeReady)
return c != nil && c.Status == metav1.ConditionTrue && c.Reason == ReasonAvailable
}

func (r *MCPServerReconciler) reconcilePermanentValidationError(
ctx context.Context,
mcpServer *mcpv1alpha1.MCPServer,
Expand Down Expand Up @@ -1317,6 +1332,13 @@ func (r *MCPServerReconciler) emitConfigurationAccepted(mcpServer *mcpv1alpha1.M
r.Recorder.Eventf(mcpServer, nil, corev1.EventTypeNormal, ReasonValid, eventActionConfigurationAccepted, "%s", "MCPServer configuration is valid; Accepted=True")
}

func (r *MCPServerReconciler) emitServerReady(mcpServer *mcpv1alpha1.MCPServer) {
if r.Recorder == nil {
return
}
r.Recorder.Eventf(mcpServer, nil, corev1.EventTypeNormal, ReasonAvailable, eventActionServerReady, "MCPServer %s is ready; Ready=True", mcpServer.Name)
}

func (r *MCPServerReconciler) applyStatus(
ctx context.Context,
mcpServer *mcpv1alpha1.MCPServer,
Expand Down
15 changes: 15 additions & 0 deletions internal/controller/mcpserver_controller_conditions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,3 +659,18 @@ var _ = Describe("reconcileReadyCondition", func() {
Expect(condition.Message).To(ContainSubstring("1 of 1 instances healthy"))
})
})

var _ = Describe("status condition helpers", func() {
It("readyConditionIsAvailable returns true only for Ready=True with reason Available", func() {
Expect(readyConditionIsAvailable(nil)).To(BeFalse())
Expect(readyConditionIsAvailable([]metav1.Condition{
{Type: ConditionTypeReady, Status: metav1.ConditionTrue, Reason: ReasonMCPEndpointUnavailable},
})).To(BeFalse())
Expect(readyConditionIsAvailable([]metav1.Condition{
{Type: ConditionTypeReady, Status: metav1.ConditionFalse, Reason: ReasonAvailable},
})).To(BeFalse())
Expect(readyConditionIsAvailable([]metav1.Condition{
{Type: ConditionTypeReady, Status: metav1.ConditionTrue, Reason: ReasonAvailable},
})).To(BeTrue())
})
})
64 changes: 64 additions & 0 deletions internal/controller/mcpserver_controller_handshake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,70 @@ var _ = Describe("MCPServer Controller - MCP Handshake Validation", func() {
Expect(dialCount).To(Equal(0))
})

It("should emit a Normal ServerReady event only when Ready transitions to Available after handshake", func() {
shouldFail := true
reconciler, fr := newReconcilerForTestWithFakeEvents(k8sClient, k8sClient.Scheme())
reconciler.MCPDialer = func(ctx context.Context, url string) (*mcpv1alpha1.MCPServerInfo, error) {
if shouldFail {
return nil, fmt.Errorf("intentional failure")
}
return &mcpv1alpha1.MCPServerInfo{
Name: "test-server",
ProtocolVersion: "2025-03-26",
}, nil
}

By("Initial reconciliation creates deployment")
_, err := reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())
drainFakeRecorderEvents(fr)

By("Simulating deployment becoming available")
deployment := &appsv1.Deployment{}
Expect(k8sClient.Get(ctx, client.ObjectKey{
Name: resourceName, Namespace: "default",
}, deployment)).To(Succeed())

deployment.Status.Replicas = 1
deployment.Status.ReadyReplicas = 1
deployment.Status.Conditions = []appsv1.DeploymentCondition{
{Type: appsv1.DeploymentAvailable, Status: corev1.ConditionTrue},
{Type: appsv1.DeploymentProgressing, Status: corev1.ConditionTrue},
}
Expect(k8sClient.Status().Update(ctx, deployment)).To(Succeed())

By("Reconciling with handshake failure — no ServerReady event")
_, err = reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())
drainFakeRecorderEvents(fr)

By("Successful handshake — ServerReady event emitted once")
shouldFail = false
_, err = reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

var serverReadyEvent string
Eventually(fr.Events).Should(Receive(&serverReadyEvent))
Expect(serverReadyEvent).To(ContainSubstring(corev1.EventTypeNormal))
Expect(serverReadyEvent).To(ContainSubstring(ReasonAvailable))
Expect(serverReadyEvent).To(ContainSubstring(resourceName))
Expect(serverReadyEvent).To(ContainSubstring("Ready=True"))

By("Second reconcile — no duplicate ServerReady event")
_, err = reconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

Consistently(fr.Events, 300*time.Millisecond, 20*time.Millisecond).ShouldNot(Receive())
})

It("should pass a context with timeout to the dialer", func() {
var receivedCtx context.Context
reconciler := &MCPServerReconciler{
Expand Down
11 changes: 11 additions & 0 deletions internal/controller/mcpserver_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ func newReconcilerForTest(cli client.Client, sch *runtime.Scheme) *MCPServerReco
return r
}

// drainFakeRecorderEvents removes all pending events from a fake recorder channel.
func drainFakeRecorderEvents(fr *events.FakeRecorder) {
for {
select {
case <-fr.Events:
default:
return
}
}
}

// newTestMCPServer returns an MCPServer with standard test defaults:
// namespace "default", SourceTypeContainerImage with ref
// "docker.io/library/test-image:latest", and port 8080.
Expand Down