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
124 changes: 124 additions & 0 deletions design/EP-2046-chat-mcp-ui-widgets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# EP-2046: Chat UI support for MCP UI widgets (MCP Apps)

* Issue: [#2046](https://github.com/kagent-dev/kagent/issues/2046)

## Background

The Model Context Protocol is gaining an "Apps"/UI extension
(`@modelcontextprotocol/ext-apps`, rendered via `@mcp-ui/client`) that lets an MCP
server attach an interactive HTML/UI **resource** to a tool. When an agent calls
such a tool, the client can render a live widget instead of (or in addition to) raw
tool-call JSON.

kagent's chat today renders tool calls as collapsible JSON. This EP makes the chat
MCP-App–aware: when a tool call maps to an MCP app resource, the chat renders the
app inline in a sandboxed frame and brokers messages between the app and the chat
(send a message on the user's behalf, surface "visible" tool calls, proxy resource
reads and tool calls back to the originating MCP server).

## Motivation

- Let MCP servers deliver rich, interactive results (forms, boards, charts, live
progress) directly in the kagent chat.
- Provide the in-chat rendering half of the kagent plugin story (the sidebar/plugin
half is EP-2047; the first consumer is the Kanban task-progress widget, EP-2048).

### Goals

- Discover MCP app resources per MCP server and associate them with tool calls.
- Render the app via a sandboxed renderer inside chat messages / tool-call display.
- Broker host↔app messaging: `sendMessage`, visible tool calls, and proxying of
resource reads and tool calls to the backend MCP server.
- Backend endpoints to list an MCP server's tools, read its resources, and call its
tools on behalf of the UI.

### Non-Goals

- The sidebar plugin/registration mechanism (EP-2047).
- Shipping a specific MCP app (the Kanban task-progress app is EP-2048).
- File-upload / artifact handling — note the chat files carry adjacent
file-upload/minimap code (see "Adjacent code" below); that feature is tracked
separately and is **not** part of this EP's scope.

## Implementation Details

### Backend

- **`go/adk/pkg/mcp/registry.go`** — `CreateToolsets` now also returns the set of
**MCP-app–capable tool names** (tools whose MCP server advertises a UI resource),
so the agent can treat their results specially.
- **`go/adk/pkg/agent/mcp_apps.go`** — `MakeMCPAppModelResultCallback`: for
MCP-app tools, keep the rich tool payload in chat history for UI rendering while
compacting what is sent back to the model (avoids redundant polling/tool churn).
Wired in `agent.go` only when `len(mcpAppToolNames) > 0`.
- **`go/core/internal/httpserver/handlers/mcpapps.go`** — `MCPAppsHandler` with
`HandleListTools`, `HandleCallTool`, `HandleReadResource`, exposed under
`/api/mcp-apps/{namespace}/{name}/...`. (Only the MCP-apps hunks of the shared
`server.go`/`handlers.go` are included here; the plugins hunks belong to EP-2047.)

### UI (`ui/src`)

- **`components/mcp-apps/McpAppRenderer.tsx`** — renders an MCP app resource via
`@mcp-ui/client` in a sandbox, wiring its `onUIAction`/resource-read/tool-call
callbacks to the backend; `McpAppsInspector.tsx` is a standalone inspector view
(also surfaced at `app/servers/[namespace]/[name]/apps/page.tsx`, and reachable
from an **"MCP Apps"** entry added to the per-server menu in
`components/mcp/McpServersView.tsx`).
- **`components/chat/ChatMcpAppsContext.tsx`** — context that maps a tool name to its
MCP app (`getMcpAppForTool`) and brokers `sendMessage` / `McpAppVisibleToolCall`
between an app and the chat.
- **`components/chat/ChatLayoutUI.tsx`** — mounts `ChatMcpAppsProvider` around the
chat subtree so the MCP-app context is active for every chat session (without this
mount, tool calls never resolve to apps and no widget renders).
- **`components/chat/ChatInterface.tsx`, `ChatMessage.tsx`, `ToolCallDisplay.tsx`,
`components/ToolDisplay.tsx`** — render the app for MCP-app tool calls and forward
app actions.
- **`app/actions/mcp-apps.ts`** + **`app/api/mcp-apps/.../{resources,tools/.../call}`**
— server actions / BFF routes calling the backend MCP-apps endpoints.
- **`public/sandbox_proxy.html`** — sandbox proxy document for the app iframe.

### New dependencies (`ui/package.json`)

- `@mcp-ui/client` `^7.1.1`
- `@modelcontextprotocol/ext-apps` `^1.7.1`
- `@modelcontextprotocol/sdk` `^1.29.0`

The lockfile (`ui/package-lock.json`) and the generated `ui/public/mockServiceWorker.js`
(MSW worker, bumped `2.14.2` → `2.14.6`) are regenerated as a side effect of resolving
the new dependency tree.

### Adjacent code

Per the agreed split, the chat files (`ChatInterface.tsx`, `ChatMessage.tsx`,
`messageHandlers.ts`) are taken whole and therefore also carry the chat
**file-upload** (`lib/fileUpload.ts`, `chat/FileAttachment.tsx`) and **minimap**
(`chat/ChatMinimap.tsx`) UI that was developed alongside MCP apps. These are
included so the chat compiles, but are not the subject of this EP; the file-upload
backend (artifact extraction, `save_artifact`) is intentionally **excluded**.

## Test Plan

- **Unit (Go):** `registry_test.go` (MCP-app tool-name detection) and
`mcp_apps_test.go` (model-result callback). `go build ./adk/... ./core/...` and
test compilation pass.
- **Unit (UI):** `getMcpAppForTool` mapping (`ChatMcpAppsContext.test.tsx`); mcp-apps
server actions (`actions/__tests__/mcp-apps.test.ts`); and a regression test
(`chat/__tests__/ChatLayoutUI.test.tsx`) asserting `ChatLayoutUI` mounts
`ChatMcpAppsProvider` around the chat so widgets can render.
- **Manual / e2e:** point the chat at an MCP server exposing a UI resource; confirm
the widget renders inline, `sendMessage` posts to the chat, and resource/tool-call
proxying reaches the server. The Kanban task-progress widget (EP-2048) is the
reference end-to-end case.

## Alternatives

- **Render apps only in a side panel (not inline in chat):** loses the
tool-call→widget association and the conversational flow.
- **Trust the model with full tool payloads:** causes token bloat and tool churn;
hence the model-result compaction callback.

## Open Questions

- Should MCP-app rendering be opt-in per MCP server (a `spec` flag) rather than
inferred from advertised UI resources?
- How should multiple apps in a single conversation share/scope state?
8 changes: 7 additions & 1 deletion go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func CreateGoogleADKAgentWithSubagentSessionIDs(ctx context.Context, agentConfig
if stsPlugin != nil {
dynamicHeaderProvider = stsPlugin.HeaderProvider
}
toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, propagateToken, dynamicHeaderProvider)
toolsets, mcpAppToolNames := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, propagateToken, dynamicHeaderProvider)
subagentSessionIDs := make(map[string]string)

var remoteAgentTools []tool.Tool
Expand Down Expand Up @@ -116,6 +116,12 @@ func CreateGoogleADKAgentWithSubagentSessionIDs(ctx context.Context, agentConfig
beforeToolCallbacks = append(beforeToolCallbacks, MakeApprovalCallback(approvalSet))
beforeModelCallbacks = append(beforeModelCallbacks, MakeStripConfirmationPartsCallback())
}
if len(mcpAppToolNames) > 0 {
// For MCP App-capable tools, keep rich tool payloads in chat history for UI rendering,
// but compact what is sent back to the model to avoid redundant polling/tool churn.
log.Info("Wiring MCP App model result callback", "toolCount", len(mcpAppToolNames))
beforeModelCallbacks = append(beforeModelCallbacks, MakeMCPAppModelResultCallback(mcpAppToolNames))
}
beforeToolCallbacks = append(beforeToolCallbacks, makeBeforeToolCallback(log))

llmAgentConfig := llmagent.Config{
Expand Down
102 changes: 102 additions & 0 deletions go/adk/pkg/agent/mcp_apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package agent

import (
"encoding/json"

mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"google.golang.org/adk/agent"
"google.golang.org/adk/agent/llmagent"
adkmodel "google.golang.org/adk/model"
)

// mcpAppRenderedNotice is the terminal message the model sees in place of an
// MCP App tool's render payload. An MCP App tool (one that declares a UI
// resourceUri) produces an interactive view that is displayed to the user and
// refreshes itself in-place via its own app-only tool calls. The model must
// treat a successful render as a completed, self-updating artifact; otherwise it
// tends to re-invoke the rendering tool on every "refresh", flooding the chat
// with duplicate app cards. This notice is protocol-oriented: it applies to any
// tool carrying a UI resourceUri, independent of the tool's name or payload keys.
const mcpAppRenderedNotice = "The interactive UI for this tool has been rendered to the user and now updates live inside the app. Treat this as complete and do not call this tool again unless the user explicitly asks for it."

// MakeMCPAppModelResultCallback replaces what the model sees for MCP App
// (UI-rendering) tool results: instead of the heavy render payload it receives a
// terminal directive (see mcpAppRenderedNotice). The full result is still
// streamed to the UI separately, so this only changes the model's view and
// prevents the model from looping on the rendering tool. Errors are passed
// through so the model can still react to and recover from failures.
func MakeMCPAppModelResultCallback(appToolNames map[string]bool) llmagent.BeforeModelCallback {
return func(_ agent.CallbackContext, req *adkmodel.LLMRequest) (*adkmodel.LLMResponse, error) {
for _, content := range req.Contents {
if content == nil {
continue
}
for _, part := range content.Parts {
if part == nil || part.FunctionResponse == nil || !appToolNames[part.FunctionResponse.Name] {
continue
}
part.FunctionResponse.Response = compactMCPAppModelResponse(part.FunctionResponse.Response)
}
}
return nil, nil
}
}

// compactMCPAppModelResponse rewrites an MCP App tool result for the model.
//
// The model exchanges tool results as a generic map (genai
// FunctionResponse.Response), but the payload is really an MCP
// [mcpsdk.CallToolResult]. We decode it into that typed result so the logic
// works against real fields (IsError, Content, Meta, StructuredContent) rather
// than poking at string keys. If the payload isn't a recognizable MCP result we
// leave it untouched.
func compactMCPAppModelResponse(response map[string]any) map[string]any {
Comment thread
dimetron marked this conversation as resolved.
result, err := decodeCallToolResult(response)
if err != nil {
return response
}

if result.IsError {
// On error, keep the original content/meta so the model can
// diagnose and recover; only drop the heavy structured payload.
result.StructuredContent = nil
return encodeCallToolResult(result, response)
}

// On success, collapse the render payload into a terminal directive so the
// model stops re-invoking the rendering tool. Preserve _meta (e.g.
// resourceUri) in case downstream tooling relies on it.
compact := &mcpsdk.CallToolResult{
Meta: result.Meta,
Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: mcpAppRenderedNotice}},
}
return encodeCallToolResult(compact, response)
}

// decodeCallToolResult interprets a generic model-facing response map as a typed
// MCP CallToolResult.
func decodeCallToolResult(response map[string]any) (*mcpsdk.CallToolResult, error) {
raw, err := json.Marshal(response)
if err != nil {
return nil, err
}
var result mcpsdk.CallToolResult
if err := json.Unmarshal(raw, &result); err != nil {
return nil, err
}
return &result, nil
}

// encodeCallToolResult converts a typed CallToolResult back into the generic map
// the model expects, falling back to the original response if conversion fails.
func encodeCallToolResult(result *mcpsdk.CallToolResult, fallback map[string]any) map[string]any {
raw, err := json.Marshal(result)
if err != nil {
return fallback
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return fallback
}
return out
}
145 changes: 145 additions & 0 deletions go/adk/pkg/agent/mcp_apps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package agent

import (
"testing"

adkmodel "google.golang.org/adk/model"
"google.golang.org/genai"
)

func TestMakeMCPAppModelResultCallbackReplacesRenderPayloadWithNotice(t *testing.T) {
t.Parallel()

req := &adkmodel.LLMRequest{
Contents: []*genai.Content{{
Parts: []*genai.Part{{
FunctionResponse: &genai.FunctionResponse{
Name: "jenkins_monitor_build",
Response: map[string]any{
"content": []map[string]any{{
"type": "text",
"text": "Opened Jenkins Build Monitor for https://example.com/job/demo/1/ (current status: IN_PROGRESS).",
}},
"structuredContent": map[string]any{
"build": map[string]any{
"stages": []any{map[string]any{"name": "Deploy", "status": "IN_PROGRESS"}},
},
"polling_data": "large payload",
},
"_meta": map[string]any{
"ui": map[string]any{
"resourceUri": "ui://jenkins-mcp/build-monitor",
},
},
},
},
}},
}},
}

callback := MakeMCPAppModelResultCallback(map[string]bool{"jenkins_monitor_build": true})
if _, err := callback(nil, req); err != nil {
t.Fatalf("callback returned error: %v", err)
}

got := req.Contents[0].Parts[0].FunctionResponse.Response

// Success render payload should be collapsed into the terminal notice so the
// model stops re-invoking the rendering tool.
content, ok := got["content"].([]any)
if !ok || len(content) != 1 {
t.Fatalf("content not replaced with notice: %#v", got["content"])
}
part, ok := content[0].(map[string]any)
if !ok || part["text"] != mcpAppRenderedNotice {
t.Fatalf("notice text missing: %#v", content[0])
}

// Should strip structuredContent (heavy render payload).
if _, ok := got["structuredContent"]; ok {
t.Fatalf("structuredContent should be stripped, got: %#v", got)
}

// Should preserve _meta
meta, ok := got["_meta"].(map[string]any)
if !ok {
t.Fatalf("_meta not preserved: %#v", got["_meta"])
}
if _, ok := meta["ui"]; !ok {
t.Fatalf("_meta.ui not preserved: %#v", meta)
}
}

func TestMakeMCPAppModelResultCallbackPreservesIsError(t *testing.T) {
t.Parallel()

req := &adkmodel.LLMRequest{
Contents: []*genai.Content{{
Parts: []*genai.Part{{
FunctionResponse: &genai.FunctionResponse{
Name: "jenkins_monitor_build",
Response: map[string]any{
"content": []map[string]any{{
"type": "text",
"text": "Tool execution failed.",
}},
"structuredContent": map[string]any{"error": "connection timeout"},
"isError": true,
},
},
}},
}},
}

callback := MakeMCPAppModelResultCallback(map[string]bool{"jenkins_monitor_build": true})
if _, err := callback(nil, req); err != nil {
t.Fatalf("callback returned error: %v", err)
}

got := req.Contents[0].Parts[0].FunctionResponse.Response

// Should preserve isError
isErr, ok := got["isError"].(bool)
if !ok || !isErr {
t.Fatalf("isError not preserved or false: %#v", got["isError"])
}

// Should still strip structuredContent
if _, ok := got["structuredContent"]; ok {
t.Fatalf("structuredContent should be stripped")
}
}

func TestMakeMCPAppModelResultCallbackLeavesNonAppToolsAlone(t *testing.T) {
t.Parallel()

original := map[string]any{
"output": map[string]any{"answer": 42},
"content": []map[string]any{{
"type": "text",
"text": "Answer is 42",
}},
}
req := &adkmodel.LLMRequest{
Contents: []*genai.Content{{
Parts: []*genai.Part{{
FunctionResponse: &genai.FunctionResponse{
Name: "regular_tool",
Response: original,
},
}},
}},
}

callback := MakeMCPAppModelResultCallback(map[string]bool{"some_app_tool": true})
if _, err := callback(nil, req); err != nil {
t.Fatalf("callback returned error: %v", err)
}

got := req.Contents[0].Parts[0].FunctionResponse.Response

// Non-app tools should pass through unchanged
if _, ok := got["output"]; !ok {
t.Fatalf("non-app tool response modified: %#v", got)
}
}
Loading
Loading