-
Notifications
You must be signed in to change notification settings - Fork 621
feat(MCP UI) chat widgets #2052
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dimetron
wants to merge
1
commit into
kagent-dev:main
Choose a base branch
from
dimetron:feature/chat-mcp-ui-widgets
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.