diff --git a/internal/controller/mcpserver_controller.go b/internal/controller/mcpserver_controller.go index 0596183d..caae202b 100644 --- a/internal/controller/mcpserver_controller.go +++ b/internal/controller/mcpserver_controller.go @@ -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 @@ -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() @@ -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 { @@ -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, @@ -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, diff --git a/internal/controller/mcpserver_controller_conditions_test.go b/internal/controller/mcpserver_controller_conditions_test.go index 7083e7b5..ef9f2b15 100644 --- a/internal/controller/mcpserver_controller_conditions_test.go +++ b/internal/controller/mcpserver_controller_conditions_test.go @@ -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()) + }) +}) diff --git a/internal/controller/mcpserver_controller_handshake_test.go b/internal/controller/mcpserver_controller_handshake_test.go index e60299f0..32cb1061 100644 --- a/internal/controller/mcpserver_controller_handshake_test.go +++ b/internal/controller/mcpserver_controller_handshake_test.go @@ -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{ diff --git a/internal/controller/mcpserver_controller_test.go b/internal/controller/mcpserver_controller_test.go index aad662b7..cc2dbd44 100644 --- a/internal/controller/mcpserver_controller_test.go +++ b/internal/controller/mcpserver_controller_test.go @@ -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.