Skip to content
Open
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
73 changes: 53 additions & 20 deletions go/core/internal/mcp/mcp_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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{},
Expand All @@ -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,
Expand All @@ -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(),
}
Comment on lines +185 to +187
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")
Expand Down
60 changes: 60 additions & 0 deletions go/core/internal/mcp/mcp_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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},
}
}
Comment on lines +90 to +95

// 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
Expand Down
Loading