Skip to content
47 changes: 46 additions & 1 deletion go/core/internal/a2a/a2a_registrar.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type A2ARegistrar struct {
sandboxA2AURL string
authenticator auth.AuthProvider
a2aBaseOptions []a2aclient.Option
agentObserver AgentObserver
}

type AgentObserver interface {
NotifyAgentsChanged(ctx context.Context)
}

var _ manager.Runnable = (*A2ARegistrar)(nil)
Expand All @@ -45,6 +50,7 @@ func NewA2ARegistrar(
streamingMaxBuf int,
streamingInitialBuf int,
streamingTimeout time.Duration,
agentObserver AgentObserver,
) (*A2ARegistrar, error) {
if clientRegistry == nil {
return nil, fmt.Errorf("clientRegistry must not be nil")
Expand All @@ -61,6 +67,7 @@ func NewA2ARegistrar(
a2aclient.WithBuffer(streamingInitialBuf, streamingMaxBuf),
debugOpt(),
},
agentObserver: agentObserver,
}

return reg, nil
Expand Down Expand Up @@ -102,19 +109,29 @@ func (a *A2ARegistrar) registerAgentInformer(ctx context.Context, prototype v1al
}
if err := a.upsertAgentHandler(ctx, agent, log); err != nil {
log.Error(err, "failed to upsert A2A handler", "agent", common.GetObjectRef(agent))
return
}
a.notifyAgentChange(ctx)
},
UpdateFunc: func(oldObj, newObj any) {
oldAgent, ok1 := informerAgentObject(oldObj)
newAgent, ok2 := informerAgentObject(newObj)
if !ok1 || !ok2 {
return
}
if oldAgent.GetGeneration() != newAgent.GetGeneration() || !sameAgentSpec(oldAgent, newAgent) {
specChanged := oldAgent.GetGeneration() != newAgent.GetGeneration() || !sameAgentSpec(oldAgent, newAgent)
if specChanged {
if err := a.upsertAgentHandler(ctx, newAgent, log); err != nil {
log.Error(err, "failed to upsert A2A handler", "agent", common.GetObjectRef(newAgent))
return
}
}
// Also notify when readiness conditions change so subscribers don't
// hold stale agent lists (the resource filter uses Accepted +
// DeploymentReady, which are status conditions, not spec fields).
if specChanged || agentReadinessChanged(oldAgent, newAgent) {
a.notifyAgentChange(ctx)
}
},
DeleteFunc: func(obj any) {
agent, ok := deletedInformerAgentObject(obj)
Expand All @@ -125,6 +142,7 @@ func (a *A2ARegistrar) registerAgentInformer(ctx context.Context, prototype v1al
a.handlerMux.RemoveAgentHandler(ref)
a.clientRegistry.delete(ref)
log.V(1).Info("removed A2A handler", "agent", ref)
a.notifyAgentChange(ctx)
},
}); err != nil {
return fmt.Errorf("failed to add informer event handler for %T: %w", prototype, err)
Expand All @@ -133,6 +151,33 @@ func (a *A2ARegistrar) registerAgentInformer(ctx context.Context, prototype v1al
return nil
}

func (a *A2ARegistrar) notifyAgentChange(ctx context.Context) {
if a.agentObserver != nil {
a.agentObserver.NotifyAgentsChanged(ctx)
}
}

func agentReadinessChanged(oldAgent, newAgent v1alpha2.AgentObject) bool {
return isAgentReady(oldAgent) != isAgentReady(newAgent)
}

func isAgentReady(agent v1alpha2.AgentObject) bool {
status := agent.GetAgentStatus()
if status == nil {
return false
}
deploymentReady, accepted := false, false
for _, c := range status.Conditions {
if c.Type == "Ready" && c.Reason == "DeploymentReady" && string(c.Status) == "True" {
deploymentReady = true
}
if c.Type == "Accepted" && string(c.Status) == "True" {
accepted = true
}
}
return deploymentReady && accepted
}

func sameAgentSpec(oldAgent, newAgent v1alpha2.AgentObject) bool {
oldSpec := oldAgent.GetAgentSpec()
newSpec := newAgent.GetAgentSpec()
Expand Down
92 changes: 71 additions & 21 deletions go/core/internal/mcp/mcp_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package mcp

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
Expand Down Expand Up @@ -40,7 +41,7 @@ type AgentSummary struct {
}

type InvokeAgentInput struct {
Agent string `json:"agent" jsonschema:"Agent reference in format namespace/name"`
Agent string `json:"agent" jsonschema:"Agent reference in format namespace/name. To find a list of available sources, use the 'agents' resource."`
Task string `json:"task" jsonschema:"Task to run"`
ContextID string `json:"context_id,omitempty" jsonschema:"Optional A2A context ID to continue a conversation"`
}
Expand All @@ -65,7 +66,12 @@ func NewMCPHandler(kubeClient client.Client, agentClients *a2a.AgentClientRegist
Name: "kagent-agents",
Version: version.Version,
}
server := mcpsdk.NewServer(impl, nil)
server := mcpsdk.NewServer(impl, &mcpsdk.ServerOptions{
// No-op handlers enable subscription tracking in the SDK; actual
// notifications are sent via NotifyAgentsChanged.
SubscribeHandler: func(context.Context, *mcpsdk.SubscribeRequest) error { return nil },
UnsubscribeHandler: func(context.Context, *mcpsdk.UnsubscribeRequest) error { return nil },
})
handler.server = server

// Add list_agents tool.
Expand Down Expand Up @@ -97,6 +103,17 @@ func NewMCPHandler(kubeClient client.Client, agentClients *a2a.AgentClientRegist
handler.handleInvokeAgent,
)

// Add agents resource for clients that pre-populate context
server.AddResource(
&mcpsdk.Resource{
URI: "kagent://agents",
Name: "agents",
Description: "List of invokable kagent agents (accepted + deploymentReady)",
MIMEType: "application/json",
},
handler.readAgentsResource,
)
Comment on lines +106 to +115

// Create HTTP handler
var httpOpts *mcpsdk.StreamableHTTPOptions
if env.KagentMCPStateless.Get() {
Expand All @@ -112,23 +129,14 @@ func NewMCPHandler(kubeClient client.Client, agentClients *a2a.AgentClientRegist
return handler, nil
}

// 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")

// listReadyAgents returns agents that are accepted and deployment-ready.
func (h *MCPHandler) listReadyAgents(ctx context.Context) ([]AgentSummary, error) {
agentList := &v1alpha2.AgentList{}
if err := h.kubeClient.List(ctx, agentList); err != nil {
return &mcpsdk.CallToolResult{
Content: []mcpsdk.Content{
&mcpsdk.TextContent{Text: fmt.Sprintf("Failed to list agents: %v", err)},
},
IsError: true,
}, ListAgentsOutput{}, nil
return nil, err
}

agents := make([]AgentSummary, 0)
agents := make([]AgentSummary, 0, len(agentList.Items))
for _, agent := range agentList.Items {
// Check if agent is accepted and deployment ready
deploymentReady := false
accepted := false
for _, condition := range agent.Status.Conditions {
Expand All @@ -139,18 +147,30 @@ func (h *MCPHandler) handleListAgents(ctx context.Context, req *mcpsdk.CallToolR
accepted = true
}
}

if !accepted || !deploymentReady {
continue
}

ref := agent.Namespace + "/" + agent.Name
description := agent.Spec.Description
agents = append(agents, AgentSummary{
Ref: ref,
Description: description,
Ref: agent.Namespace + "/" + agent.Name,
Description: agent.Spec.Description,
})
}
return agents, nil
}

// 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")

agents, err := h.listReadyAgents(ctx)
if err != nil {
return &mcpsdk.CallToolResult{
Content: []mcpsdk.Content{
&mcpsdk.TextContent{Text: fmt.Sprintf("Failed to list agents: %v", err)},
},
IsError: true,
}, ListAgentsOutput{}, nil
}

log.Info("Listed agents", "count", len(agents))

Expand Down Expand Up @@ -179,6 +199,36 @@ func (h *MCPHandler) handleListAgents(ctx context.Context, req *mcpsdk.CallToolR
}, output, nil
}

// readAgentsResource handles reads of the kagent://agents resource.
func (h *MCPHandler) readAgentsResource(ctx context.Context, req *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
agents, err := h.listReadyAgents(ctx)
if err != nil {
return nil, fmt.Errorf("listing agents: %w", err)
}
data, err := json.Marshal(agents)
if err != nil {
return nil, fmt.Errorf("marshaling agents: %w", err)
}
return &mcpsdk.ReadResourceResult{
Contents: []*mcpsdk.ResourceContents{{
URI: "kagent://agents",
MIMEType: "application/json",
Text: string(data),
}},
}, nil
}

// NotifyAgentsChanged sends a resources/updated notification for kagent://agents
// to all subscribed clients. Called by A2ARegistrar when agents are added, updated,
// or removed.
func (h *MCPHandler) NotifyAgentsChanged(ctx context.Context) {
if err := h.server.ResourceUpdated(ctx, &mcpsdk.ResourceUpdatedNotificationParams{
URI: "kagent://agents",
}); err != nil {
ctrllog.FromContext(ctx).WithName("mcp-handler").Error(err, "failed to send resource updated notification")
}
}

// handleInvokeAgent handles the invoke_agent MCP tool
func (h *MCPHandler) handleInvokeAgent(ctx context.Context, req *mcpsdk.CallToolRequest, input InvokeAgentInput) (*mcpsdk.CallToolResult, InvokeAgentOutput, error) {
log := ctrllog.FromContext(ctx).WithName("mcp-handler").WithValues("tool", "invoke_agent")
Expand Down
28 changes: 15 additions & 13 deletions go/core/pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -611,9 +611,22 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne
os.Exit(1)
}

clientRegistry := a2a.NewAgentClientRegistry()

// Create MCP handler that invokes agents directly via their A2A clients,
// bypassing the controller's own HTTP A2A listener.
mcpHandler, err := mcp.NewMCPHandler(
mgr.GetClient(),
clientRegistry,
extensionCfg.Authenticator,
)
if err != nil {
setupLog.Error(err, "unable to create MCP handler")
os.Exit(1)
}

// Register A2A handlers on all replicas
a2aHandler := a2a.NewA2AHttpMux(httpserver.APIPathA2A, httpserver.APIPathA2ASandboxes, extensionCfg.Authenticator)
clientRegistry := a2a.NewAgentClientRegistry()
a2aRegistrar, err := a2a.NewA2ARegistrar(
mgr.GetCache(),
a2aHandler,
Expand All @@ -624,6 +637,7 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne
int(cfg.Streaming.MaxBufSize.Value()),
int(cfg.Streaming.InitialBufSize.Value()),
cfg.Streaming.Timeout,
mcpHandler,
)
if err != nil {
setupLog.Error(err, "unable to create a2a registrar")
Expand All @@ -634,18 +648,6 @@ func Start(getExtensionConfig GetExtensionConfig, migrationRunner MigrationRunne
os.Exit(1)
}

// Create MCP handler that invokes agents directly via their A2A clients,
// bypassing the controller's own HTTP A2A listener.
mcpHandler, err := mcp.NewMCPHandler(
mgr.GetClient(),
clientRegistry,
extensionCfg.Authenticator,
)
if err != nil {
setupLog.Error(err, "unable to create MCP handler")
os.Exit(1)
}

// +kubebuilder:scaffold:builder
if metricsCertWatcher != nil {
setupLog.Info("Adding metrics certificate watcher to manager")
Expand Down
Loading
Loading