From ad3870ac93dbfdb510101617123b12a6e9603438 Mon Sep 17 00:00:00 2001 From: Diego Braga Date: Sat, 4 Jul 2026 17:16:06 +0200 Subject: [PATCH 1/2] feat(core): emit Kubernetes Events on controller reconciles Add an optional EventRecorder to the Agent, ModelConfig, MCPServerTool and RemoteMCPServer controllers so reconcile outcomes are visible via 'kubectl describe', mirroring the pattern already used by the agentharness-substrate controller. Emitted events: - Agent / ModelConfig / RemoteMCPServer: Normal Accepted on success, Warning ReconcileFailed on error. - MCPServerTool: Normal ToolsDiscovered on success, Warning ValidationFailed for spec validation errors, Warning ReconcileFailed for transient errors. Recording is nil-guarded on both the Recorder and Client, so it is a no-op when either is unwired (e.g. in tests) and behaviour is unchanged. RBAC is already covered by the leader-election role (events: create,patch). Recorders are wired from pkg/app/app.go via mgr.GetEventRecorder. Refs: #2148 Signed-off-by: Diego Braga --- .../internal/controller/agent_controller.go | 32 ++++++- .../controller/mcp_server_tool_controller.go | 29 +++++++ .../mcp_server_tool_controller_events_test.go | 84 +++++++++++++++++++ .../controller/modelconfig_controller.go | 31 ++++++- .../remote_mcp_server_controller.go | 26 ++++++ go/core/pkg/app/app.go | 8 ++ 6 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 go/core/internal/controller/mcp_server_tool_controller_events_test.go diff --git a/go/core/internal/controller/agent_controller.go b/go/core/internal/controller/agent_controller.go index e13a03d5c9..a42edd93f2 100644 --- a/go/core/internal/controller/agent_controller.go +++ b/go/core/internal/controller/agent_controller.go @@ -19,9 +19,11 @@ package controller import ( "context" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -44,6 +46,12 @@ type AgentController struct { Scheme *runtime.Scheme Reconciler reconciler.KagentReconciler AdkTranslator agent_translator.AdkApiTranslator + // Client is used to fetch the reconciled object so Events can be emitted + // against it. Optional; event emission is skipped when nil. + Client client.Client + // Recorder emits Kubernetes Events on reconcile transitions. Optional; + // event emission is skipped when nil, keeping behaviour unchanged. + Recorder events.EventRecorder } // +kubebuilder:rbac:groups=kagent.dev,resources=agents,verbs=get;list;watch;create;update;patch;delete @@ -59,7 +67,29 @@ type AgentController struct { func (r *AgentController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { _ = log.FromContext(ctx) - return ctrl.Result{}, r.Reconciler.ReconcileKagentAgent(ctx, req) + if err := r.Reconciler.ReconcileKagentAgent(ctx, req); err != nil { + r.recordEvent(ctx, req, "Warning", "ReconcileFailed", "Reconcile", + "failed to reconcile Agent: %s", err.Error()) + return ctrl.Result{}, err + } + r.recordEvent(ctx, req, "Normal", "Accepted", "Reconcile", "Agent reconciled successfully") + return ctrl.Result{}, nil +} + +// recordEvent emits a Kubernetes Event against the reconciled Agent. It is a +// no-op when no Recorder/Client is wired or the object can no longer be fetched. +func (r *AgentController) recordEvent(ctx context.Context, req ctrl.Request, eventtype, reason, action, note string, args ...any) { + if r.Recorder == nil || r.Client == nil { + return + } + agent := &v1alpha2.Agent{} + if err := r.Client.Get(ctx, req.NamespacedName, agent); err != nil { + if !apierrors.IsNotFound(err) { + log.FromContext(ctx).V(1).Info("unable to fetch Agent for event recording", "error", err.Error()) + } + return + } + r.Recorder.Eventf(agent, nil, eventtype, reason, action, note, args...) } // SetupWithManager sets up the controller with the Manager. diff --git a/go/core/internal/controller/mcp_server_tool_controller.go b/go/core/internal/controller/mcp_server_tool_controller.go index 67c3490eeb..5a7b235128 100644 --- a/go/core/internal/controller/mcp_server_tool_controller.go +++ b/go/core/internal/controller/mcp_server_tool_controller.go @@ -26,10 +26,13 @@ import ( agent_translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" "github.com/kagent-dev/kmcp/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -41,6 +44,11 @@ var mcpServerGK = schema.GroupKind{Group: "kagent.dev", Kind: "MCPServer"} type MCPServerToolController struct { Scheme *runtime.Scheme Reconciler reconciler.KagentReconciler + // Client is used to fetch the reconciled object so Events can be emitted + // against it. Optional; event emission is skipped when nil. + Client client.Client + // Recorder emits Kubernetes Events on reconcile transitions. Optional. + Recorder events.EventRecorder } // +kubebuilder:rbac:groups=kagent.dev,resources=mcpservers,verbs=get;list;watch @@ -53,19 +61,40 @@ func (r *MCPServerToolController) Reconcile(ctx context.Context, req ctrl.Reques // Check if this is a validation error that requires user action var validationErr *agent_translator.ValidationError if errors.As(err, &validationErr) { + r.recordEvent(ctx, req, "Warning", "ValidationFailed", "Reconcile", + "MCPServer validation failed: %s", err.Error()) // Validation error - don't retry until MCPServer spec is updated // Return empty result with no error to avoid exponential backoff return ctrl.Result{}, nil } + r.recordEvent(ctx, req, "Warning", "ReconcileFailed", "Reconcile", + "failed to reconcile MCPServer: %s", err.Error()) // Transient error - return error to trigger exponential backoff retry return ctrl.Result{}, err } + r.recordEvent(ctx, req, "Normal", "ToolsDiscovered", "Reconcile", "MCPServer tools discovered successfully") // Success - requeue after 60s to refresh tool server status return ctrl.Result{ RequeueAfter: 60 * time.Second, }, nil } +// recordEvent emits a Kubernetes Event against the reconciled MCPServer. +// No-op when no Recorder/Client is wired or the object cannot be fetched. +func (r *MCPServerToolController) recordEvent(ctx context.Context, req ctrl.Request, eventtype, reason, action, note string, args ...any) { + if r.Recorder == nil || r.Client == nil { + return + } + server := &v1alpha1.MCPServer{} + if err := r.Client.Get(ctx, req.NamespacedName, server); err != nil { + if !apierrors.IsNotFound(err) { + log.FromContext(ctx).V(1).Info("unable to fetch MCPServer for event recording", "error", err.Error()) + } + return + } + r.Recorder.Eventf(server, nil, eventtype, reason, action, note, args...) +} + // SetupWithManager sets up the controller with the Manager. func (r *MCPServerToolController) SetupWithManager(mgr ctrl.Manager) error { if _, err := mgr.GetRESTMapper().RESTMapping(mcpServerGK); err != nil { diff --git a/go/core/internal/controller/mcp_server_tool_controller_events_test.go b/go/core/internal/controller/mcp_server_tool_controller_events_test.go new file mode 100644 index 0000000000..eea48d4494 --- /dev/null +++ b/go/core/internal/controller/mcp_server_tool_controller_events_test.go @@ -0,0 +1,84 @@ +package controller + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kagent-dev/kmcp/api/v1alpha1" + + agenttranslator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" +) + +// TestMCPServerToolController_RecordsEvents verifies the controller emits a +// Kubernetes Event with the expected type/reason for each reconcile outcome. +func TestMCPServerToolController_RecordsEvents(t *testing.T) { + ctx := context.Background() + + scheme := runtime.NewScheme() + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + + server := &v1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "test-server"}, + } + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "test", Name: "test-server"}} + + testCases := []struct { + name string + reconcileErr error + wantEventType string + wantReason string + }{ + {"success emits ToolsDiscovered", nil, "Normal", "ToolsDiscovered"}, + {"validation error emits ValidationFailed", agenttranslator.NewValidationError("invalid port"), "Warning", "ValidationFailed"}, + {"transient error emits ReconcileFailed", errors.New("database connection failed"), "Warning", "ReconcileFailed"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(server).Build() + recorder := events.NewFakeRecorder(1) + + controller := &MCPServerToolController{ + Reconciler: &fakeReconciler{reconcileMCPServerError: tc.reconcileErr}, + Client: cl, + Recorder: recorder, + } + + if _, err := controller.Reconcile(ctx, req); tc.wantEventType == "Warning" && tc.wantReason == "ReconcileFailed" { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + select { + case got := <-recorder.Events: + assert.Contains(t, got, tc.wantEventType) + assert.Contains(t, got, tc.wantReason) + default: + t.Fatalf("expected an event with reason %q, got none", tc.wantReason) + } + }) + } +} + +// TestMCPServerToolController_NoRecorderNoPanic verifies event recording is a +// safe no-op when no Recorder/Client is wired (the default wiring). +func TestMCPServerToolController_NoRecorderNoPanic(t *testing.T) { + controller := &MCPServerToolController{Reconciler: &fakeReconciler{}} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "test", Name: "test-server"}} + // Must not panic despite nil Recorder/Client. + if _, err := controller.Reconcile(context.Background(), req); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/go/core/internal/controller/modelconfig_controller.go b/go/core/internal/controller/modelconfig_controller.go index b4d00201c9..bbe55d3682 100644 --- a/go/core/internal/controller/modelconfig_controller.go +++ b/go/core/internal/controller/modelconfig_controller.go @@ -22,8 +22,10 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -44,6 +46,11 @@ var ( type ModelConfigController struct { Scheme *runtime.Scheme Reconciler reconciler.KagentReconciler + // Client is used to fetch the reconciled object so Events can be emitted + // against it. Optional; event emission is skipped when nil. + Client client.Client + // Recorder emits Kubernetes Events on reconcile transitions. Optional. + Recorder events.EventRecorder } // +kubebuilder:rbac:groups=kagent.dev,resources=modelconfigs,verbs=get;list;watch;create;update;patch;delete @@ -53,7 +60,29 @@ type ModelConfigController struct { func (r *ModelConfigController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { _ = log.FromContext(ctx) - return ctrl.Result{}, r.Reconciler.ReconcileKagentModelConfig(ctx, req) + if err := r.Reconciler.ReconcileKagentModelConfig(ctx, req); err != nil { + r.recordEvent(ctx, req, "Warning", "ReconcileFailed", "Reconcile", + "failed to reconcile ModelConfig: %s", err.Error()) + return ctrl.Result{}, err + } + r.recordEvent(ctx, req, "Normal", "Accepted", "Reconcile", "ModelConfig reconciled successfully") + return ctrl.Result{}, nil +} + +// recordEvent emits a Kubernetes Event against the reconciled ModelConfig. +// No-op when no Recorder/Client is wired or the object cannot be fetched. +func (r *ModelConfigController) recordEvent(ctx context.Context, req ctrl.Request, eventtype, reason, action, note string, args ...any) { + if r.Recorder == nil || r.Client == nil { + return + } + mc := &v1alpha2.ModelConfig{} + if err := r.Client.Get(ctx, req.NamespacedName, mc); err != nil { + if !apierrors.IsNotFound(err) { + log.FromContext(ctx).V(1).Info("unable to fetch ModelConfig for event recording", "error", err.Error()) + } + return + } + r.Recorder.Eventf(mc, nil, eventtype, reason, action, note, args...) } // SetupWithManager sets up the controller with the Manager. diff --git a/go/core/internal/controller/remote_mcp_server_controller.go b/go/core/internal/controller/remote_mcp_server_controller.go index a0641870a0..0081f81eca 100644 --- a/go/core/internal/controller/remote_mcp_server_controller.go +++ b/go/core/internal/controller/remote_mcp_server_controller.go @@ -24,8 +24,10 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -44,6 +46,11 @@ var ( type RemoteMCPServerController struct { Scheme *runtime.Scheme Reconciler reconciler.KagentReconciler + // Client is used to fetch the reconciled object so Events can be emitted + // against it. Optional; event emission is skipped when nil. + Client client.Client + // Recorder emits Kubernetes Events on reconcile transitions. Optional. + Recorder events.EventRecorder } // +kubebuilder:rbac:groups=kagent.dev,resources=remotemcpservers,verbs=get;list;watch;create;update;patch;delete @@ -56,15 +63,34 @@ func (r *RemoteMCPServerController) Reconcile(ctx context.Context, req ctrl.Requ err := r.Reconciler.ReconcileKagentRemoteMCPServer(ctx, req) if err != nil { + r.recordEvent(ctx, req, "Warning", "ReconcileFailed", "Reconcile", + "failed to reconcile RemoteMCPServer: %s", err.Error()) // Return zero result when there's an error - controller-runtime will handle backoff return ctrl.Result{}, err } + r.recordEvent(ctx, req, "Normal", "Accepted", "Reconcile", "RemoteMCPServer reconciled successfully") // Success - requeue after 60s to refresh tool server status return ctrl.Result{ RequeueAfter: 60 * time.Second, }, nil } +// recordEvent emits a Kubernetes Event against the reconciled RemoteMCPServer. +// No-op when no Recorder/Client is wired or the object cannot be fetched. +func (r *RemoteMCPServerController) recordEvent(ctx context.Context, req ctrl.Request, eventtype, reason, action, note string, args ...any) { + if r.Recorder == nil || r.Client == nil { + return + } + server := &v1alpha2.RemoteMCPServer{} + if err := r.Client.Get(ctx, req.NamespacedName, server); err != nil { + if !apierrors.IsNotFound(err) { + log.FromContext(ctx).V(1).Info("unable to fetch RemoteMCPServer for event recording", "error", err.Error()) + } + return + } + r.Recorder.Eventf(server, nil, eventtype, reason, action, note, args...) +} + // SetupWithManager sets up the controller with the Manager. func (r *RemoteMCPServerController) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 7f990952b1..2bb8921855 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -566,6 +566,8 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne if err := (&controller.MCPServerToolController{ Scheme: mgr.GetScheme(), Reconciler: rcnclr, + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorder("mcpservertool-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Service") os.Exit(1) @@ -575,6 +577,8 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne Scheme: mgr.GetScheme(), Reconciler: rcnclr, AdkTranslator: apiTranslator, + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorder("agent-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Agent") os.Exit(1) @@ -622,6 +626,8 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne if err = (&controller.ModelConfigController{ Scheme: mgr.GetScheme(), Reconciler: rcnclr, + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorder("modelconfig-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ModelConfig") os.Exit(1) @@ -638,6 +644,8 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne if err = (&controller.RemoteMCPServerController{ Scheme: mgr.GetScheme(), Reconciler: rcnclr, + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorder("remotemcpserver-controller"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "RemoteMCPServer") os.Exit(1) From 30b64bce1deb329e24b21ff6eee032bfb8b56e63 Mon Sep 17 00:00:00 2001 From: Diego Braga Date: Thu, 9 Jul 2026 17:03:32 +0200 Subject: [PATCH 2/2] feat(core): only emit MCP success Events on meaningful transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCPServerTool and RemoteMCPServer controllers requeue every 60s to refresh tool status, and previously recorded a Normal success Event on every reconcile — flooding kubectl events and adding control-plane writes even when nothing changed. Gate the success Event on a pending spec change (generation != observedGeneration, captured before reconcile); periodic no-op refreshes no longer emit. Warning events (ReconcileFailed/ValidationFailed) still emit every time. The Agent/ModelConfig controllers are unaffected (they don't requeue periodically). Refs: #2148 Signed-off-by: Diego Braga --- .../controller/mcp_server_tool_controller.go | 24 +++++++++++++- .../mcp_server_tool_controller_events_test.go | 31 ++++++++++++++++++- .../remote_mcp_server_controller.go | 24 +++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/go/core/internal/controller/mcp_server_tool_controller.go b/go/core/internal/controller/mcp_server_tool_controller.go index 5a7b235128..0528e83a48 100644 --- a/go/core/internal/controller/mcp_server_tool_controller.go +++ b/go/core/internal/controller/mcp_server_tool_controller.go @@ -56,6 +56,11 @@ type MCPServerToolController struct { func (r *MCPServerToolController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { _ = log.FromContext(ctx) + // This controller requeues every 60s to refresh tool status. Only emit the + // success Event on a meaningful transition (a spec change not yet observed + // in status), not on every periodic refresh, to avoid flooding Events. + specChanged := r.hasPendingGeneration(ctx, req) + err := r.Reconciler.ReconcileKagentMCPServer(ctx, req) if err != nil { // Check if this is a validation error that requires user action @@ -72,13 +77,30 @@ func (r *MCPServerToolController) Reconcile(ctx context.Context, req ctrl.Reques // Transient error - return error to trigger exponential backoff retry return ctrl.Result{}, err } - r.recordEvent(ctx, req, "Normal", "ToolsDiscovered", "Reconcile", "MCPServer tools discovered successfully") + if specChanged { + r.recordEvent(ctx, req, "Normal", "ToolsDiscovered", "Reconcile", "MCPServer tools discovered successfully") + } // Success - requeue after 60s to refresh tool server status return ctrl.Result{ RequeueAfter: 60 * time.Second, }, nil } +// hasPendingGeneration reports whether the MCPServer has spec changes not yet +// reflected in status (generation != observedGeneration). It gates success +// Events to meaningful transitions. Returns true (emit) when the object can't +// be fetched, so events are not silently dropped. +func (r *MCPServerToolController) hasPendingGeneration(ctx context.Context, req ctrl.Request) bool { + if r.Client == nil { + return true + } + server := &v1alpha1.MCPServer{} + if err := r.Client.Get(ctx, req.NamespacedName, server); err != nil { + return true + } + return server.Generation != server.Status.ObservedGeneration +} + // recordEvent emits a Kubernetes Event against the reconciled MCPServer. // No-op when no Recorder/Client is wired or the object cannot be fetched. func (r *MCPServerToolController) recordEvent(ctx context.Context, req ctrl.Request, eventtype, reason, action, note string, args ...any) { diff --git a/go/core/internal/controller/mcp_server_tool_controller_events_test.go b/go/core/internal/controller/mcp_server_tool_controller_events_test.go index eea48d4494..b1e366f8b3 100644 --- a/go/core/internal/controller/mcp_server_tool_controller_events_test.go +++ b/go/core/internal/controller/mcp_server_tool_controller_events_test.go @@ -28,8 +28,10 @@ func TestMCPServerToolController_RecordsEvents(t *testing.T) { scheme := runtime.NewScheme() utilruntime.Must(v1alpha1.AddToScheme(scheme)) + // Generation != ObservedGeneration => a pending spec change, so the success + // Event is emitted (see the no-op case below for the periodic-refresh path). server := &v1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "test-server"}, + ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "test-server", Generation: 1}, } req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "test", Name: "test-server"}} @@ -72,6 +74,33 @@ func TestMCPServerToolController_RecordsEvents(t *testing.T) { } } +// TestMCPServerToolController_SkipsSuccessEventOnPeriodicRefresh verifies that a +// successful reconcile with no pending spec change (generation == +// observedGeneration, i.e. a periodic 60s refresh) does NOT emit a success +// Event, so the Events feed is not flooded. +func TestMCPServerToolController_SkipsSuccessEventOnPeriodicRefresh(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + + server := &v1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "test-server", Generation: 3}, + Status: v1alpha1.MCPServerStatus{ObservedGeneration: 3}, + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(server).Build() + recorder := events.NewFakeRecorder(1) + controller := &MCPServerToolController{Reconciler: &fakeReconciler{}, Client: cl, Recorder: recorder} + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "test", Name: "test-server"}} + + if _, err := controller.Reconcile(context.Background(), req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + select { + case got := <-recorder.Events: + t.Fatalf("expected no success Event on a periodic refresh, got %q", got) + default: + } +} + // TestMCPServerToolController_NoRecorderNoPanic verifies event recording is a // safe no-op when no Recorder/Client is wired (the default wiring). func TestMCPServerToolController_NoRecorderNoPanic(t *testing.T) { diff --git a/go/core/internal/controller/remote_mcp_server_controller.go b/go/core/internal/controller/remote_mcp_server_controller.go index 0081f81eca..2cfa3f4be2 100644 --- a/go/core/internal/controller/remote_mcp_server_controller.go +++ b/go/core/internal/controller/remote_mcp_server_controller.go @@ -61,6 +61,11 @@ type RemoteMCPServerController struct { func (r *RemoteMCPServerController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { _ = log.FromContext(ctx) + // This controller requeues every 60s to refresh tool status. Only emit the + // success Event on a meaningful transition (a spec change not yet observed + // in status), not on every periodic refresh, to avoid flooding Events. + specChanged := r.hasPendingGeneration(ctx, req) + err := r.Reconciler.ReconcileKagentRemoteMCPServer(ctx, req) if err != nil { r.recordEvent(ctx, req, "Warning", "ReconcileFailed", "Reconcile", @@ -68,13 +73,30 @@ func (r *RemoteMCPServerController) Reconcile(ctx context.Context, req ctrl.Requ // Return zero result when there's an error - controller-runtime will handle backoff return ctrl.Result{}, err } - r.recordEvent(ctx, req, "Normal", "Accepted", "Reconcile", "RemoteMCPServer reconciled successfully") + if specChanged { + r.recordEvent(ctx, req, "Normal", "Accepted", "Reconcile", "RemoteMCPServer reconciled successfully") + } // Success - requeue after 60s to refresh tool server status return ctrl.Result{ RequeueAfter: 60 * time.Second, }, nil } +// hasPendingGeneration reports whether the RemoteMCPServer has spec changes not +// yet reflected in status (generation != observedGeneration). It gates success +// Events to meaningful transitions. Returns true (emit) when the object can't +// be fetched, so events are not silently dropped. +func (r *RemoteMCPServerController) hasPendingGeneration(ctx context.Context, req ctrl.Request) bool { + if r.Client == nil { + return true + } + server := &v1alpha2.RemoteMCPServer{} + if err := r.Client.Get(ctx, req.NamespacedName, server); err != nil { + return true + } + return server.Generation != server.Status.ObservedGeneration +} + // recordEvent emits a Kubernetes Event against the reconciled RemoteMCPServer. // No-op when no Recorder/Client is wired or the object cannot be fetched. func (r *RemoteMCPServerController) recordEvent(ctx context.Context, req ctrl.Request, eventtype, reason, action, note string, args ...any) {