From cf097f0c7ef7eb89bbd064040e4ca191965ca2d2 Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:43:23 +0530 Subject: [PATCH] fix(mcp): include SandboxAgents in list_agents and invoke_agent listReadyAgents only enumerated v1alpha2.Agent and gated readiness on the literal reason "DeploymentReady", so ready SandboxAgents were never listed. A SandboxAgent reports its Ready condition with reason "WorkloadReady" (reconciler.reconcileSandboxAgentStatus), while a declarative Agent uses "DeploymentReady", so a fully ready SandboxAgent (Accepted=True, Ready=True) was invisible to the list_agents tool and the kagent://agents resource, and invoke_agent reported it as "not found or not ready" even though it appears in the REST /agents API and the UI. List both Agent and SandboxAgent and evaluate readiness over the shared AgentObject interface, accepting both AgentReadyReasonDeploymentReady and AgentReadyReasonWorkloadReady, matching the A2A registrar (isAgentReady) and the REST agents handler. Update the tool and resource descriptions to say "accepted + ready" now that both workload types are covered. Closes: #2123 Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- go/core/internal/mcp/mcp_handler.go | 73 +++++++++++++++++------- go/core/internal/mcp/mcp_handler_test.go | 60 +++++++++++++++++++ 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/go/core/internal/mcp/mcp_handler.go b/go/core/internal/mcp/mcp_handler.go index 940482803c..b85da61fbc 100644 --- a/go/core/internal/mcp/mcp_handler.go +++ b/go/core/internal/mcp/mcp_handler.go @@ -11,10 +11,12 @@ import ( "github.com/google/jsonschema-go/jsonschema" "github.com/kagent-dev/kagent/go/api/v1alpha2" "github.com/kagent-dev/kagent/go/core/internal/a2a" + "github.com/kagent-dev/kagent/go/core/internal/controller/reconciler" "github.com/kagent-dev/kagent/go/core/internal/version" "github.com/kagent-dev/kagent/go/core/pkg/auth" "github.com/kagent-dev/kagent/go/core/pkg/env" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" ctrllog "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -83,7 +85,7 @@ func NewMCPHandler(kubeClient client.Client, agentClients *a2a.AgentClientRegist server, &mcpsdk.Tool{ Name: "list_agents", - Description: "List invokable kagent agents (accepted + deploymentReady)", + Description: "List invokable kagent agents (accepted + ready)", InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{}, @@ -108,7 +110,7 @@ func NewMCPHandler(kubeClient client.Client, agentClients *a2a.AgentClientRegist &mcpsdk.Resource{ URI: "kagent://agents", Name: "agents", - Description: "List of invokable kagent agents (accepted + deploymentReady)", + Description: "List of invokable kagent agents (accepted + ready)", MIMEType: "application/json", }, handler.readAgentsResource, @@ -129,35 +131,66 @@ func NewMCPHandler(kubeClient client.Client, agentClients *a2a.AgentClientRegist return handler, nil } -// listReadyAgents returns agents that are accepted and deployment-ready. +// listReadyAgents returns agents that are accepted and ready. It covers both +// declarative Agents and SandboxAgents; a SandboxAgent reports its Ready +// condition with reason "WorkloadReady" while a declarative Agent uses +// "DeploymentReady", so both are treated as ready here, matching the REST +// /agents API and the A2A registrar. func (h *MCPHandler) listReadyAgents(ctx context.Context) ([]AgentSummary, error) { agentList := &v1alpha2.AgentList{} if err := h.kubeClient.List(ctx, agentList); err != nil { return nil, err } - agents := make([]AgentSummary, 0, len(agentList.Items)) - for _, agent := range agentList.Items { - deploymentReady := false - accepted := false - for _, condition := range agent.Status.Conditions { - if condition.Type == "Ready" && condition.Reason == "DeploymentReady" && condition.Status == "True" { - deploymentReady = true - } - if condition.Type == "Accepted" && condition.Status == "True" { - accepted = true - } + sandboxAgentList := &v1alpha2.SandboxAgentList{} + if err := h.kubeClient.List(ctx, sandboxAgentList); err != nil { + return nil, err + } + + agents := make([]AgentSummary, 0, len(agentList.Items)+len(sandboxAgentList.Items)) + for i := range agentList.Items { + if summary, ok := readyAgentSummary(&agentList.Items[i]); ok { + agents = append(agents, summary) } - if !accepted || !deploymentReady { - continue + } + for i := range sandboxAgentList.Items { + if summary, ok := readyAgentSummary(&sandboxAgentList.Items[i]); ok { + agents = append(agents, summary) } - agents = append(agents, AgentSummary{ - Ref: agent.Namespace + "/" + agent.Name, - Description: agent.Spec.Description, - }) } return agents, nil } +// readyAgentSummary reports whether the given agent is accepted and ready, and +// if so returns its AgentSummary. +func readyAgentSummary(agent v1alpha2.AgentObject) (AgentSummary, bool) { + status := agent.GetAgentStatus() + if status == nil { + return AgentSummary{}, false + } + ready, accepted := false, false + for _, condition := range status.Conditions { + if condition.Type == v1alpha2.AgentConditionTypeReady && condition.Status == metav1.ConditionTrue { + switch condition.Reason { + case reconciler.AgentReadyReasonDeploymentReady, reconciler.AgentReadyReasonWorkloadReady: + ready = true + } + } + if condition.Type == v1alpha2.AgentConditionTypeAccepted && condition.Status == metav1.ConditionTrue { + accepted = true + } + } + if !accepted || !ready { + return AgentSummary{}, false + } + summary := AgentSummary{ + Ref: agent.GetNamespace() + "/" + agent.GetName(), + } + if spec := agent.GetAgentSpec(); spec != nil { + summary.Description = spec.Description + } + return summary, true +} + // handleListAgents handles the list_agents MCP tool func (h *MCPHandler) handleListAgents(ctx context.Context, req *mcpsdk.CallToolRequest, input ListAgentsInput) (*mcpsdk.CallToolResult, ListAgentsOutput, error) { log := ctrllog.FromContext(ctx).WithName("mcp-handler").WithValues("tool", "list_agents") diff --git a/go/core/internal/mcp/mcp_handler_test.go b/go/core/internal/mcp/mcp_handler_test.go index d274461f3a..807c92c844 100644 --- a/go/core/internal/mcp/mcp_handler_test.go +++ b/go/core/internal/mcp/mcp_handler_test.go @@ -16,7 +16,9 @@ import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) @@ -82,6 +84,64 @@ func TestListAgentsInputSchemaHasProperties(t *testing.T) { require.Equal(t, false, schema["additionalProperties"], "additionalProperties must remain false") } +// readyConditions returns the Accepted=True and Ready=True conditions an agent +// has once its workload is ready. The Ready reason differs by workload type: +// declarative Agents report "DeploymentReady", SandboxAgents "WorkloadReady". +func readyConditions(readyReason string) []metav1.Condition { + return []metav1.Condition{ + {Type: "Accepted", Status: metav1.ConditionTrue, Reason: "AgentReconciled"}, + {Type: "Ready", Status: metav1.ConditionTrue, Reason: readyReason}, + } +} + +// TestListReadyAgents_IncludesSandboxAgents asserts that listReadyAgents lists +// both declarative Agents and SandboxAgents and that it treats an agent as ready +// whether its Ready condition reason is "DeploymentReady" (Agent) or +// "WorkloadReady" (SandboxAgent), matching the behaviour of the REST /agents API +// and the A2A registrar. Regression test for +// https://github.com/kagent-dev/kagent/issues/2123. +func TestListReadyAgents_IncludesSandboxAgents(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, v1alpha2.AddToScheme(scheme)) + + objs := []client.Object{ + &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "ready-agent"}, + Spec: v1alpha2.AgentSpec{Description: "declarative agent"}, + Status: v1alpha2.AgentStatus{Conditions: readyConditions("DeploymentReady")}, + }, + &v1alpha2.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "ready-sandbox"}, + Spec: v1alpha2.SandboxAgentSpec{AgentSpec: v1alpha2.AgentSpec{Description: "sandbox agent"}}, + Status: v1alpha2.AgentStatus{Conditions: readyConditions("WorkloadReady")}, + }, + &v1alpha2.SandboxAgent{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "pending-sandbox"}, + Status: v1alpha2.AgentStatus{Conditions: []metav1.Condition{ + {Type: "Accepted", Status: metav1.ConditionTrue, Reason: "AgentReconciled"}, + {Type: "Ready", Status: metav1.ConditionFalse, Reason: "SandboxBackendNotConfigured"}, + }}, + }, + } + + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + h, err := NewMCPHandler(kubeClient, nil, nil) + require.NoError(t, err) + + agents, err := h.listReadyAgents(context.Background()) + require.NoError(t, err) + + refs := make(map[string]string, len(agents)) + for _, a := range agents { + refs[a.Ref] = a.Description + } + + assert.Contains(t, refs, "default/ready-agent", "ready declarative Agent should be listed") + assert.Contains(t, refs, "default/ready-sandbox", "ready SandboxAgent should be listed") + assert.NotContains(t, refs, "default/pending-sandbox", "not-ready SandboxAgent must be excluded") + assert.Equal(t, "sandbox agent", refs["default/ready-sandbox"], "SandboxAgent description should be surfaced") +} + // a2aBackend is a fake A2A server that records whether it was called. type a2aBackend struct { server *httptest.Server