Add session tools to built-in kagent-agents MCP server#1334
Add session tools to built-in kagent-agents MCP server#1334dobesv wants to merge 1 commit intokagent-dev:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds session-management MCP tools to the controller’s built-in MCP server and registers the controller endpoint as a RemoteMCPServer so agents can consume these tools.
Changes:
- Added a Helm template to create a
RemoteMCPServerpointing at the controller’s/mcpendpoint. - Extended the controller config/startup to pass DB + UI base URL into the MCP handler.
- Implemented MCP tools for creating sessions, listing session events, and sending session messages, with accompanying unit tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
helm/kagent/templates/toolserver-controller.yaml |
Registers controller MCP endpoint as a RemoteMCPServer. |
go/pkg/app/app.go |
Adds --ui-base-url config and wires DB + UI URL into MCP handler construction. |
go/internal/mcp/mcp_handler.go |
Implements session MCP tools and A2A send helper using DB-backed sessions. |
go/internal/mcp/mcp_handler_test.go |
Adds unit tests for the new session MCP tools. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if session.AgentID != nil { | ||
| agentRef := utils.ConvertToKubernetesIdentifier(*session.AgentID) | ||
| if err := h.sendA2AMessage(ctx, agentRef, &session.ID, input.Content); err != nil { | ||
| log.Error(err, "Failed to dispatch message to agent", "sessionID", session.ID, "agent", agentRef) | ||
| msg := fmt.Sprintf("Failed to dispatch message to agent: %v. WARNING: do NOT resend the same message — it may have been partially delivered. Check the session events first.", err) | ||
| return &mcpsdk.CallToolResult{ | ||
| Content: []mcpsdk.Content{ | ||
| &mcpsdk.TextContent{Text: msg}, | ||
| }, | ||
| IsError: true, | ||
| }, SendSessionMessageOutput{Message: msg}, nil | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
If session.AgentID is nil, this handler returns "Message sent successfully." without dispatching anything. Since SendSessionMessageInput implies delivering a message to the agent, this should return an error when the session has no associated agent (or explicitly document/rename the tool if "no-op" is intended).
| if session.AgentID != nil { | |
| agentRef := utils.ConvertToKubernetesIdentifier(*session.AgentID) | |
| if err := h.sendA2AMessage(ctx, agentRef, &session.ID, input.Content); err != nil { | |
| log.Error(err, "Failed to dispatch message to agent", "sessionID", session.ID, "agent", agentRef) | |
| msg := fmt.Sprintf("Failed to dispatch message to agent: %v. WARNING: do NOT resend the same message — it may have been partially delivered. Check the session events first.", err) | |
| return &mcpsdk.CallToolResult{ | |
| Content: []mcpsdk.Content{ | |
| &mcpsdk.TextContent{Text: msg}, | |
| }, | |
| IsError: true, | |
| }, SendSessionMessageOutput{Message: msg}, nil | |
| } | |
| } | |
| if session.AgentID == nil { | |
| msg := fmt.Sprintf("Cannot send message: session %s has no associated agent.", session.ID) | |
| log.Error(fmt.Errorf(msg), "Session has no associated agent", "sessionID", session.ID) | |
| return &mcpsdk.CallToolResult{ | |
| Content: []mcpsdk.Content{ | |
| &mcpsdk.TextContent{Text: msg}, | |
| }, | |
| IsError: true, | |
| }, SendSessionMessageOutput{Message: msg}, nil | |
| } | |
| agentRef := utils.ConvertToKubernetesIdentifier(*session.AgentID) | |
| if err := h.sendA2AMessage(ctx, agentRef, &session.ID, input.Content); err != nil { | |
| log.Error(err, "Failed to dispatch message to agent", "sessionID", session.ID, "agent", agentRef) | |
| msg := fmt.Sprintf("Failed to dispatch message to agent: %v. WARNING: do NOT resend the same message — it may have been partially delivered. Check the session events first.", err) | |
| return &mcpsdk.CallToolResult{ | |
| Content: []mcpsdk.Content{ | |
| &mcpsdk.TextContent{Text: msg}, | |
| }, | |
| IsError: true, | |
| }, SendSessionMessageOutput{Message: msg}, nil | |
| } |
| // Parse agent reference (namespace/name or just name) | ||
| _, agentName, ok := strings.Cut(input.Agent, "/") | ||
| if !ok { | ||
| return &mcpsdk.CallToolResult{ | ||
| Content: []mcpsdk.Content{ | ||
| &mcpsdk.TextContent{Text: "agent must be in format 'namespace/name'"}, | ||
| }, | ||
| IsError: true, | ||
| }, CreateSessionOutput{}, nil | ||
| } |
There was a problem hiding this comment.
The comment says the agent ref can be "namespace/name or just name", but the implementation requires a slash and errors otherwise. Either update the comment to match behavior, or support name-only refs (e.g., via utils.ParseRefString with a default namespace).
|
|
||
| // handleGetSessionEvents handles the get_session_events MCP tool | ||
| func (h *MCPHandler) handleGetSessionEvents(ctx context.Context, req *mcpsdk.CallToolRequest, input GetSessionEventsInput) (*mcpsdk.CallToolResult, GetSessionEventsOutput, error) { | ||
| log := ctrllog.FromContext(ctx).WithName("mcp-handler").WithValues("tool", "get_session_events") |
There was a problem hiding this comment.
The structured log value uses tool=get_session_events, but the registered MCP tool name is get_agent_session_events. Aligning these makes logs/metrics easier to correlate with tool calls.
| log := ctrllog.FromContext(ctx).WithName("mcp-handler").WithValues("tool", "get_session_events") | |
| log := ctrllog.FromContext(ctx).WithName("mcp-handler").WithValues("tool", "get_agent_session_events") |
| commandLine.StringVar(&cfg.Database.Path, "sqlite-database-path", "./kagent.db", "The path to the SQLite database file.") | ||
| commandLine.StringVar(&cfg.Database.Url, "postgres-database-url", "postgres://postgres:kagent@db.kagent.svc.cluster.local:5432/crud", "The URL of the PostgreSQL database.") | ||
|
|
||
| commandLine.StringVar(&cfg.UI.BaseURL, "ui-base-url", "http://localhost:3000", "The base URL of the UI.") |
There was a problem hiding this comment.
--ui-base-url defaults to http://localhost:3000, which will produce broken UI links when the controller runs in-cluster (it will point to the controller Pod’s localhost, not the UI Service). Consider defaulting this to an in-cluster service URL, or leaving it empty and requiring Helm/env configuration (e.g., UI_BASE_URL) so generated session URLs are valid.
| commandLine.StringVar(&cfg.UI.BaseURL, "ui-base-url", "http://localhost:3000", "The base URL of the UI.") | |
| commandLine.StringVar(&cfg.UI.BaseURL, "ui-base-url", "", "The base URL of the UI.") |
| {{- include "kagent.labels" . | nindent 4 }} | ||
| spec: | ||
| url: "http://{{ include "kagent.fullname" . }}-controller.{{ include "kagent.namespace" . }}:{{ .Values.controller.service.ports.port }}/mcp" | ||
| description: "Kagent Controller Built-in Tools (Session Management, Agent Invocation)" |
There was a problem hiding this comment.
Branding is inconsistent with the rest of the chart (e.g., toolserver-kagent.yaml uses "KAgent"). Consider changing "Kagent Controller..." to "KAgent Controller..." for consistency.
| description: "Kagent Controller Built-in Tools (Session Management, Agent Invocation)" | |
| description: "KAgent Controller Built-in Tools (Session Management, Agent Invocation)" |
| if input.UserID == "" { | ||
| input.UserID = defaultUserID | ||
| } |
There was a problem hiding this comment.
The MCP tools accept an optional user_id and default it to admin@kagent.dev, but the MCP endpoint is behind auth.AuthnMiddleware, so the authenticated user is available in the request context. As written, a caller can omit user_id (creating/administering sessions as admin) or supply an arbitrary user_id to read/write other users' sessions. Consider deriving the user ID from auth.AuthSessionFrom(ctx) (and/or rejecting mismatches) instead of trusting input.UserID.
| if input.UserID == "" { | |
| input.UserID = defaultUserID | |
| } | |
| // Derive user ID from authenticated session and ensure it matches any provided input. | |
| authSession := auth.AuthSessionFrom(ctx) | |
| if authSession == nil { | |
| log.Error(nil, "no auth session found in context") | |
| return &mcpsdk.CallToolResult{ | |
| Content: []mcpsdk.Content{ | |
| &mcpsdk.TextContent{Text: "authentication required to create a session"}, | |
| }, | |
| IsError: true, | |
| }, CreateSessionOutput{}, nil | |
| } | |
| if input.UserID != "" && input.UserID != authSession.UserID { | |
| log.Error(nil, "user_id mismatch between request and authenticated session", "input_user_id", input.UserID, "session_user_id", authSession.UserID) | |
| return &mcpsdk.CallToolResult{ | |
| Content: []mcpsdk.Content{ | |
| &mcpsdk.TextContent{Text: "user_id does not match authenticated user"}, | |
| }, | |
| IsError: true, | |
| }, CreateSessionOutput{}, nil | |
| } | |
| input.UserID = authSession.UserID |
| if input.UserID == "" { | ||
| input.UserID = defaultUserID | ||
| } |
There was a problem hiding this comment.
This tool currently allows callers to supply an arbitrary user_id (or fall back to admin@kagent.dev) when listing session events. Since the MCP endpoint is authenticated via auth.AuthnMiddleware, this should use the authenticated principal's user ID from context (or at least validate that input.UserID matches it) to prevent cross-user data access.
| if input.UserID == "" { | |
| input.UserID = defaultUserID | |
| } | |
| // Always use the internal default user ID to prevent callers from selecting arbitrary users. | |
| input.UserID = defaultUserID |
| if input.UserID == "" { | ||
| input.UserID = defaultUserID | ||
| } |
There was a problem hiding this comment.
send_agent_session_message trusts input.UserID (defaulting to admin@kagent.dev) when loading the session. With request auth already in middleware, this enables users to target other users' sessions by passing a different user_id. Use the authenticated user from auth.AuthSessionFrom(ctx) (or validate/ignore the input field).
|
Hey there, thanks for the contribution. I definitely think this is moving in an awesome direction, but I'm not sure this is the level of abstraction we want to expose to the agents. Perhaps we can look further into the patterns that ADK recommends: https://google.github.io/adk-docs/agents/multi-agents/ |
|
My main goal here is handing off some work from one interactive agent session to another. The multi-agent patterns you linked seem to be a more advanced version of the current agent delegation system which isn't really what I wanted. I thought enabling some interesting multi-agent workflows beyond my simple use case could be built on top this tool perhaps. But that's not really the main point of this relatively small change. For now I am assuming I will have to maintain my own fork of kagent more or less indefinitely. I thought I would share some of my changes back upstream in case people have a use for them. If not, feel free to close my pull requests. Even if you do not merge them, others who want these capabilities and are also maintaining their own fork can use them as a reference. Having my changes upstream and not maintaining a customized fork seems a distant dream at this point, I'm not counting on it. |
|
Note that these MCP additions also can be a way to transfer a workflow into kagent from an external MCP client as well, not just internally within kagent. If you use Think of the feature in Gemini where you can hand-off work to Jules, or in Claude where you can spawn a job into Claude for the Web. |
As I said in your other PR, we're very much open to new features, but it's also important that we keep in mind the rest of the community, and the maintainability and health of the project overall. For example, I am also very very much interested in background tasks, and how we can enable those sorts of workflows, but the best first step would be to create a design and attend the community meeting so that we can all be aligned before the feature is added. The most important thing is to make features which are useful for everyone, and also maintainable by the maintainers. The link for the community meeting can be found here: https://kagent.dev/community The meeting happens at 12:00 EST on Tuesdays, looking forward to seeing you there |
|
It did occur to me today that it might be possible to allow opening a delegated agent session in kagent UI in which case I could still use the A2A protocol to create the session and just offer a link to it to the user to continue the work. That might be a more elegant solution, if it's compatible with the way kagent works. |
This allows an agent to create an kagent session to run a task instead of simple delegation. This can be used to "hand off" a task to a different agent.
For example you can have an interactive session with a planning agent, it can hand that off to the implementation agent, and you can have an interactive session with the implementation agent.
The interactive session is desirable so the agent can ask questions or get corrections. Doing it via multiple agents is desirable so that you can pick your model parameters and system prompt separately for the task.
This combines the two together - you can setup a RemoteMCPServer that points to the kagent-controller and give the planning agent the power to create a session and give you a link to it to continue the work.
In principle this might move in the direction of having multi-agent coordination where you can interact with the agents and they interact with each other if you give them each others' session IDs or have a coordinating agent that starts multiple agents and then gives them each others' session IDs.