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