diff --git a/.gitignore b/.gitignore index dfbeebccde..0793287c67 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ python/.env .env values.local.yaml +.playwright* test_results diff --git a/contrib/tools/server-everything/README.md b/contrib/tools/server-everything/README.md new file mode 100644 index 0000000000..00a5aaf544 --- /dev/null +++ b/contrib/tools/server-everything/README.md @@ -0,0 +1,55 @@ +# Server Everything MCP + +This directory wires the public [Server Everything](https://servereverything.dev/mcp) +reference MCP server into kagent as a `RemoteMCPServer`, plus a demo `Agent` that +exercises its tools — including the `show-weather-dashboard` **MCP App** (an +interactive UI widget rendered inline in the chat). + +It is useful for verifying the chat MCP UI widget integration (EP-2046) against a +public server that uses a single dual-visibility (`["model", "app"]`) UI tool. + +## What it provides + +`server-everything` exposes demo tools; this agent enables a focused subset: + +| Tool | Purpose | +|------|---------| +| `show-weather-dashboard` | Renders an interactive weather dashboard MCP App (UI widget). Advertised via `_meta.ui.resourceUri = ui://server-everything/weather-dashboard`, visibility `["model", "app"]`. | +| `echo` | Reflects text back. | +| `get-sum` | Adds two numbers. | +| `get-tiny-image` | Returns a small sample image. | +| `get-structured-content` | Demonstrates structured tool output. | + +## Installation + +```bash +kubectl apply -f server-everything-remote-mcpserver.yaml +kubectl apply -f server-everything-agent.yaml +``` + +This creates: +- a `RemoteMCPServer` named `server-everything` pointing at `https://servereverything.dev/mcp` +- an `Agent` named `server-everything-agent` that uses the tools above + +No extra Helm values are needed — MCP App (UI widget) rendering is detected +automatically from each tool's `_meta.ui` metadata. + +## Verify + +```bash +# RemoteMCPServer should reach Accepted and discover tools +kubectl get remotemcpserver server-everything -n kagent -o yaml + +# Agent should become Ready +kubectl get agent server-everything-agent -n kagent +``` + +Then open the agent in the kagent UI and ask: **"show the weather dashboard"**. +The dashboard renders inline and updates itself in place (it re-calls +`show-weather-dashboard` from inside the widget). + +## Learn More + +- [Server Everything](https://servereverything.dev/mcp) +- [MCP Protocol](https://modelcontextprotocol.io/) +- MCP UI widgets in kagent: `design/EP-2046-chat-mcp-ui-widgets.md` diff --git a/contrib/tools/server-everything/server-everything-agent.yaml b/contrib/tools/server-everything/server-everything-agent.yaml new file mode 100644 index 0000000000..4bf5ba6b25 --- /dev/null +++ b/contrib/tools/server-everything/server-everything-agent.yaml @@ -0,0 +1,48 @@ +apiVersion: kagent.dev/v1alpha2 +kind: Agent +metadata: + name: server-everything-agent + namespace: kagent +spec: + type: Declarative + description: Demo agent exercising Server Everything MCP tools, including the weather-dashboard MCP App (UI widget). + declarative: + modelConfig: default-model-config + runtime: go + stream: true + systemMessage: |- + You are a helpful assistant that demonstrates the Server Everything MCP tools, + including an interactive weather dashboard rendered as an inline UI widget. + + # Weather (most important) + - Whenever the user asks ANYTHING about weather, forecast, temperature, or + conditions for a place, you MUST call the `show-weather-dashboard` tool. + Never answer weather questions from your own knowledge or with plain text. + - Pass the city as the `location` argument. For example, "weather in Paris" + -> call show-weather-dashboard with {"location": "Paris"}. If no city is + given, use {"location": "New York"}. + - The tool renders the dashboard inline and keeps itself updated, so after it + runs just give a one-line confirmation and let the widget show the data — + do not repeat the numbers yourself. + + # Other tools + - `echo` reflects text back, `get-sum` adds two numbers, `get-tiny-image` + returns a sample image, and `get-structured-content` returns structured + output. + - Ask for clarification only when the request is genuinely ambiguous. + - If a tool fails, point the user to https://kagent.dev for support. + + # Style + - Respond in Markdown and keep replies brief. + tools: + - type: McpServer + mcpServer: + apiGroup: kagent.dev + kind: RemoteMCPServer + name: server-everything + toolNames: + - show-weather-dashboard + - echo + - get-sum + - get-tiny-image + - get-structured-content diff --git a/contrib/tools/server-everything/server-everything-remote-mcpserver.yaml b/contrib/tools/server-everything/server-everything-remote-mcpserver.yaml new file mode 100644 index 0000000000..4b4d549ae0 --- /dev/null +++ b/contrib/tools/server-everything/server-everything-remote-mcpserver.yaml @@ -0,0 +1,15 @@ +## Server Everything MCP (reference/demo MCP server) +## https://servereverything.dev/mcp +## Exposes a set of demo tools, including the `show-weather-dashboard` +## MCP App (UI widget) advertised via tool `_meta.ui.resourceUri`. +apiVersion: kagent.dev/v1alpha2 +kind: RemoteMCPServer +metadata: + name: server-everything + namespace: kagent +spec: + url: "https://servereverything.dev/mcp" + protocol: STREAMABLE_HTTP + timeout: 30s + sseReadTimeout: 5m0s + description: "Server Everything reference MCP server with an interactive weather dashboard MCP App" diff --git a/design/EP-2046-chat-mcp-ui-widgets.md b/design/EP-2046-chat-mcp-ui-widgets.md new file mode 100644 index 0000000000..40b78695e1 --- /dev/null +++ b/design/EP-2046-chat-mcp-ui-widgets.md @@ -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? diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index fa9d633d14..c942cebed3 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -56,6 +56,7 @@ func CreateGoogleADKAgentWithSubagentSessionIDs(ctx context.Context, agentConfig dynamicHeaderProvider = stsPlugin.HeaderProvider } toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, propagateToken, dynamicHeaderProvider) + mcpAppToolNames := mcp.MCPAppToolNamesFromToolsets(toolsets) subagentSessionIDs := make(map[string]string) var remoteAgentTools []tool.Tool @@ -116,6 +117,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{ diff --git a/go/adk/pkg/agent/mcp_apps.go b/go/adk/pkg/agent/mcp_apps.go new file mode 100644 index 0000000000..43270363fb --- /dev/null +++ b/go/adk/pkg/agent/mcp_apps.go @@ -0,0 +1,103 @@ +package agent + +import ( + "encoding/json" + + "github.com/kagent-dev/kagent/go/adk/pkg/mcp" + 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 mcp.MCPAppToolNames) 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 +} diff --git a/go/adk/pkg/agent/mcp_apps_test.go b/go/adk/pkg/agent/mcp_apps_test.go new file mode 100644 index 0000000000..cec1838cf0 --- /dev/null +++ b/go/adk/pkg/agent/mcp_apps_test.go @@ -0,0 +1,146 @@ +package agent + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/adk/pkg/mcp" + 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(mcp.MCPAppToolNames{"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(mcp.MCPAppToolNames{"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(mcp.MCPAppToolNames{"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) + } +} diff --git a/go/adk/pkg/mcp/mcp_ui.go b/go/adk/pkg/mcp/mcp_ui.go new file mode 100644 index 0000000000..6bd6811f3c --- /dev/null +++ b/go/adk/pkg/mcp/mcp_ui.go @@ -0,0 +1,228 @@ +package mcp + +// MCP Apps (a.k.a. MCP UI) lets an MCP tool declare an interactive HTML view +// that the host renders inline in the chat. A tool opts in via its +// `_meta.ui.resourceUri` field, which points at a `ui://` resource the host +// fetches and renders in a sandboxed iframe; the optional `_meta.ui.visibility` +// field controls whether the tool is exposed to the model, the app, or both. +// +// This file holds the helpers that read those `_meta.ui` fields and classify +// each tool so the agent and UI surface it correctly. The metadata contract is +// defined by the MCP Apps extension, not the core MCP spec: +// +// Overview: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/extensions/apps/overview.mdx +// Full spec: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx +// +// Keep these helpers here (rather than in registry.go) so the spec mapping stays +// in one place and is easy to track as the extension evolves. + +import ( + "context" + "fmt" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + adkagent "google.golang.org/adk/agent" + "google.golang.org/adk/tool" +) + +// MCPAppToolNames is the set of MCP tool names whose results render as +// interactive MCP App (UI) widgets in the chat (the tool declares a +// `_meta.ui.resourceUri` and is visible to the model). It is used as a set, so +// the bool value is always true and only key presence is meaningful. The agent +// attaches the model-result compaction callback (see +// agent.MakeMCPAppModelResultCallback) only to these tools. Collect them from +// CreateToolsets output via MCPAppToolNamesFromToolsets. +type MCPAppToolNames map[string]bool + +// mcpAppToolset wraps an MCP toolset and records which model-visible tools +// render as MCP App widgets. Classification happens during ListTools inside +// agentVisibleToolFilter because mcpsdk.Tool.Meta is not preserved on the ADK +// tool.Tool values the toolset exposes later. +type mcpAppToolset struct { + inner tool.Toolset + appToolNames MCPAppToolNames +} + +func (m *mcpAppToolset) Name() string { + return m.inner.Name() +} + +func (m *mcpAppToolset) Tools(ctx adkagent.ReadonlyContext) ([]tool.Tool, error) { + return m.inner.Tools(ctx) +} + +// MCPAppToolNamesFromToolsets returns the union of MCP App-capable tool names +// recorded on toolsets built by CreateToolsets. +func MCPAppToolNamesFromToolsets(toolsets []tool.Toolset) MCPAppToolNames { + out := make(MCPAppToolNames) + for _, ts := range toolsets { + aware, ok := ts.(*mcpAppToolset) + if !ok { + continue + } + for name := range aware.appToolNames { + out[name] = true + } + } + return out +} + +// mcpToolKind classifies how an MCP tool is surfaced, following the MCP Apps +// extension. It is derived from the tool's `_meta.ui` block: presence of a +// `resourceUri` means the tool renders an app, and the `visibility` field +// ("model" / "app") controls who may call it. Modeling this as a single kind +// rather than a set of overlapping booleans keeps the call sites readable and +// leaves room for additional kinds without reworking signatures. +type mcpToolKind int + +const ( + // mcpToolKindModel is a regular tool exposed to the model (the LLM/agent) + // with no interactive UI. Matches `_meta.ui.visibility` of "model" or an + // absent visibility (which defaults to model-visible). + mcpToolKindModel mcpToolKind = iota + // mcpToolKindApp is a model-visible tool that also declares a + // `_meta.ui.resourceUri`, so its result renders as an interactive MCP App + // (UI) widget in the chat. The model may call it like any other tool. + mcpToolKindApp + // mcpToolKindAppOnly is hidden from the model and only callable from within + // the rendered MCP App itself (e.g. the widget's own refresh button). It + // declares `_meta.ui.visibility` of "app" without "model". + mcpToolKindAppOnly +) + +func (k mcpToolKind) String() string { + switch k { + case mcpToolKindApp: + return "app" + case mcpToolKindAppOnly: + return "app_only" + default: + return "model" + } +} + +// mcpUIMetadata holds the MCP Apps extension fields read from a tool's +// `_meta.ui` block. See the spec links at the top of this file. +type mcpUIMetadata struct { + ResourceURI string + Visibility []string +} + +func parseMCPUIMetadata(meta mcpsdk.Meta) mcpUIMetadata { + var ui mcpUIMetadata + if len(meta) == 0 { + return ui + } + if raw, ok := meta["ui"].(map[string]any); ok { + if uri, _ := raw["resourceUri"].(string); uri != "" { + ui.ResourceURI = uri + } + ui.Visibility = normalizeVisibility(raw["visibility"]) + } + if ui.ResourceURI == "" { + if uri, _ := meta["ui/resourceUri"].(string); uri != "" { + ui.ResourceURI = uri + } + } + return ui +} + +// mcpToolKindOf classifies a tool from its MCP metadata. App-only takes +// precedence: a tool hidden from the model is never surfaced to the model even +// if it also declares a UI resource. +func mcpToolKindOf(meta mcpsdk.Meta) mcpToolKind { + ui := parseMCPUIMetadata(meta) + if isAppOnlyVisibility(ui.Visibility) { + return mcpToolKindAppOnly + } + if ui.ResourceURI != "" { + return mcpToolKindApp + } + return mcpToolKindModel +} + +// isAppOnlyVisibility reports whether visibility hides a tool from the model: +// it declares "app" but not "model". Absent visibility defaults to +// model-visible. +func isAppOnlyVisibility(visibility []string) bool { + hasApp := false + for _, v := range visibility { + if v == "model" { + return false + } + if v == "app" { + hasApp = true + } + } + return hasApp +} + +// normalizeVisibility coerces the visibility field, which the MCP Apps spec +// allows as either a single string or a list of strings, into a uniform +// []string. +func normalizeVisibility(value any) []string { + switch v := value.(type) { + case string: + return []string{v} + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + +// agentVisibleToolFilter lists tools from the MCP server, filters out app-only +// tools and any not in the configured allow-list, and returns a predicate the +// toolset can apply plus the MCP App-capable tool names discovered on this +// server. +// +// Classification must happen here because MCP Apps metadata lives on +// mcpsdk.Tool.Meta, which ADK mcptoolset drops when converting to tool.Tool. +func agentVisibleToolFilter(ctx context.Context, params mcpServerParams, configuredFilter map[string]bool) (tool.Predicate, MCPAppToolNames, error) { + mcpTransport, err := createTransport(ctx, params) + if err != nil { + return nil, nil, fmt.Errorf("failed to create transport for %s: %w", params.URL, err) + } + + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "kagent-adk"}, nil) + session, err := client.Connect(ctx, mcpTransport, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to connect MCP client for %s: %w", params.URL, err) + } + defer session.Close() + + result, err := session.ListTools(ctx, &mcpsdk.ListToolsParams{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to list MCP tools for %s: %w", params.URL, err) + } + + allowedTools := make([]string, 0, len(result.Tools)) + appToolNames := make(MCPAppToolNames) + for _, t := range result.Tools { + if t == nil || t.Name == "" { + continue + } + if len(configuredFilter) > 0 && !configuredFilter[t.Name] { + continue + } + switch mcpToolKindOf(t.Meta) { + case mcpToolKindAppOnly: + // Hidden from the model; only the rendered MCP App calls it. + continue + case mcpToolKindApp: + allowedTools = append(allowedTools, t.Name) + appToolNames[t.Name] = true + default: // mcpToolKindModel + allowedTools = append(allowedTools, t.Name) + } + } + + return tool.StringPredicate(allowedTools), appToolNames, nil +} diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index 97cf20ea41..1c0bb2ee6d 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -78,9 +78,10 @@ type mcpServerParams struct { TLSDisableSystemCAs *bool } -// CreateToolsets creates toolsets from all configured HTTP and SSE MCP servers, -// returning the accumulated toolsets. Errors on individual servers are logged -// and skipped. +// CreateToolsets creates toolsets from all configured HTTP and SSE MCP servers. +// MCP App-capable tool names are attached to each returned toolset wrapper and +// can be collected in the agent via MCPAppToolNamesFromToolsets. Errors on +// individual servers are logged and skipped. // // When propagateToken is true, Authorization is forwarded to every MCP server // independently of AllowedHeaders, mirroring the Python ADKTokenPropagationPlugin @@ -308,21 +309,18 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro return rt.base.RoundTrip(req) } -// initializeToolSet fetches tools from an MCP server using Google ADK's mcptoolset. -// Returns the created toolset on success. +// initializeToolSet fetches tools from an MCP server using Google ADK's +// mcptoolset and wraps the result with any MCP App-capable tool names found +// during classification. func initializeToolSet(ctx context.Context, params mcpServerParams, toolFilter map[string]bool) (tool.Toolset, error) { mcpTransport, err := createTransport(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create transport for %s: %w", params.URL, err) } - var toolPredicate tool.Predicate - if len(toolFilter) > 0 { - allowedTools := make([]string, 0, len(toolFilter)) - for toolName := range toolFilter { - allowedTools = append(allowedTools, toolName) - } - toolPredicate = tool.StringPredicate(allowedTools) + toolPredicate, appToolNames, err := agentVisibleToolFilter(ctx, params, toolFilter) + if err != nil { + return nil, err } cfg := mcptoolset.Config{ @@ -335,5 +333,5 @@ func initializeToolSet(ctx context.Context, params mcpServerParams, toolFilter m return nil, fmt.Errorf("failed to create MCP toolset for %s: %w", params.URL, err) } - return toolset, nil + return &mcpAppToolset{inner: toolset, appToolNames: appToolNames}, nil } diff --git a/go/adk/pkg/mcp/registry_test.go b/go/adk/pkg/mcp/registry_test.go index 2931a94bd3..b19a994afc 100644 --- a/go/adk/pkg/mcp/registry_test.go +++ b/go/adk/pkg/mcp/registry_test.go @@ -7,6 +7,9 @@ import ( "testing" "github.com/a2aproject/a2a-go/a2asrv" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + adkagent "google.golang.org/adk/agent" + "google.golang.org/adk/tool" ) // a2aCtx builds a context that carries an A2A CallContext with the given headers. @@ -177,6 +180,98 @@ func TestAllowedRequestHeaders_EmptyAllowedList(t *testing.T) { } } +func TestMCPAppToolNamesFromToolsets(t *testing.T) { + t.Parallel() + + inner := &stubToolset{name: "mcp-server"} + toolsets := []tool.Toolset{ + &mcpAppToolset{inner: inner, appToolNames: MCPAppToolNames{"show_board": true}}, + &mcpAppToolset{inner: inner, appToolNames: MCPAppToolNames{"refresh": true}}, + inner, + } + + got := MCPAppToolNamesFromToolsets(toolsets) + if len(got) != 2 || !got["show_board"] || !got["refresh"] { + t.Fatalf("MCPAppToolNamesFromToolsets() = %#v, want show_board and refresh", got) + } +} + +type stubToolset struct { + name string +} + +func (s *stubToolset) Name() string { return s.name } + +func (s *stubToolset) Tools(ctx adkagent.ReadonlyContext) ([]tool.Tool, error) { + _ = ctx + return nil, nil +} + +func TestMCPToolKindOf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + meta mcpsdk.Meta + want mcpToolKind + }{ + { + name: "app visibility list is app-only", + meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"app"}}}, + want: mcpToolKindAppOnly, + }, + { + name: "app visibility string is app-only", + meta: mcpsdk.Meta{"ui": map[string]any{"visibility": "app"}}, + want: mcpToolKindAppOnly, + }, + { + name: "app-only wins over a declared resource uri", + meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"app"}, "resourceUri": "ui://forms/form.html"}}, + want: mcpToolKindAppOnly, + }, + { + name: "model and app visibility without resource is a plain model tool", + meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"model", "app"}}}, + want: mcpToolKindModel, + }, + { + name: "model and app visibility with resource renders as app", + meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"app", "model"}, "resourceUri": "ui://forms/form.html"}}, + want: mcpToolKindApp, + }, + { + name: "model only visibility is a plain model tool", + meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"model"}}}, + want: mcpToolKindModel, + }, + { + name: "resource uri in ui object renders as app", + meta: mcpsdk.Meta{"ui": map[string]any{"resourceUri": "ui://forms/form.html"}}, + want: mcpToolKindApp, + }, + { + name: "legacy resource uri key renders as app", + meta: mcpsdk.Meta{"ui/resourceUri": "ui://forms/form.html"}, + want: mcpToolKindApp, + }, + { + name: "plain tool", + meta: mcpsdk.Meta{}, + want: mcpToolKindModel, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := mcpToolKindOf(tt.meta); got != tt.want { + t.Fatalf("mcpToolKindOf() = %v, want %v", got, tt.want) + } + }) + } +} + // TestAllowedRequestHeaders_CaseInsensitiveLookup verifies that matching between // the configured allowedHeaders and the incoming request headers is case-insensitive // regardless of which side is lowercased or uppercased. diff --git a/go/adk/pkg/models/ollama_adk.go b/go/adk/pkg/models/ollama_adk.go index 46c6b1aa3c..318628cf4f 100644 --- a/go/adk/pkg/models/ollama_adk.go +++ b/go/adk/pkg/models/ollama_adk.go @@ -61,6 +61,9 @@ func (m *OllamaModel) GenerateContent(ctx context.Context, req *model.LLMRequest // generateStreaming handles streaming responses from Ollama. func (m *OllamaModel) generateStreaming(ctx context.Context, modelName string, messages []api.Message, tools []api.Tool, options map[string]any, yield func(*model.LLMResponse, error) bool) { var aggregatedText strings.Builder + // Ollama streams tool calls in intermediate chunks (done=false), not in the + // final done=true chunk, so accumulate them across all chunks. + var aggregatedToolCalls []api.ToolCall streamValue := true chatReq := &api.ChatRequest{ @@ -72,6 +75,9 @@ func (m *OllamaModel) generateStreaming(ctx context.Context, modelName string, m } err := m.Client.Chat(ctx, chatReq, func(resp api.ChatResponse) error { + // Accumulate tool calls from any chunk that carries them. + aggregatedToolCalls = append(aggregatedToolCalls, resp.Message.ToolCalls...) + // Handle content if resp.Message.Content != "" { aggregatedText.WriteString(resp.Message.Content) @@ -101,8 +107,8 @@ func (m *OllamaModel) generateStreaming(ctx context.Context, modelName string, m finalParts = append(finalParts, &genai.Part{Text: text}) } - // Convert tool calls from final message - for _, tc := range resp.Message.ToolCalls { + // Convert tool calls accumulated across all streamed chunks. + for _, tc := range aggregatedToolCalls { if tc.Function.Name != "" { functionCall := &genai.FunctionCall{ Name: tc.Function.Name, diff --git a/go/adk/pkg/models/ollama_stream_test.go b/go/adk/pkg/models/ollama_stream_test.go new file mode 100644 index 0000000000..eca6aefb76 --- /dev/null +++ b/go/adk/pkg/models/ollama_stream_test.go @@ -0,0 +1,82 @@ +package models + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/ollama/ollama/api" + "google.golang.org/adk/model" + "google.golang.org/genai" +) + +// TestGenerateStreamingAggregatesToolCalls verifies that tool calls emitted by +// Ollama in an intermediate (done=false) chunk are not dropped when the final +// (done=true) chunk carries no tool calls. This mirrors real Ollama streaming +// behavior (e.g. glm-5.2) where tool_calls arrive before the terminating chunk. +func TestGenerateStreamingAggregatesToolCalls(t *testing.T) { + // Stream: first an empty content chunk with the tool call, then a terminating + // chunk with no tool calls. + chunks := []string{ + `{"model":"glm-5.2","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_1","function":{"index":0,"name":"show-weather-dashboard","arguments":{"location":"New York"}}}]},"done":false}`, + `{"model":"glm-5.2","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","prompt_eval_count":10,"eval_count":5}`, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + flusher, _ := w.(http.Flusher) + for _, c := range chunks { + _, _ = w.Write([]byte(c + "\n")) + if flusher != nil { + flusher.Flush() + } + } + })) + defer srv.Close() + + baseURL, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse url: %v", err) + } + + m := &OllamaModel{ + Config: &OllamaConfig{Model: "glm-5.2"}, + Client: api.NewClient(baseURL, http.DefaultClient), + } + + req := &model.LLMRequest{ + Contents: []*genai.Content{ + {Role: "user", Parts: []*genai.Part{{Text: "show weather in NY"}}}, + }, + } + + var functionCalls []*genai.FunctionCall + for resp, err := range m.GenerateContent(context.Background(), req, true) { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.ErrorMessage != "" { + t.Fatalf("unexpected response error: %s", resp.ErrorMessage) + } + if resp.Content == nil { + continue + } + for _, part := range resp.Content.Parts { + if part.FunctionCall != nil { + functionCalls = append(functionCalls, part.FunctionCall) + } + } + } + + if len(functionCalls) != 1 { + t.Fatalf("expected 1 function call, got %d", len(functionCalls)) + } + if functionCalls[0].Name != "show-weather-dashboard" { + t.Errorf("expected tool name show-weather-dashboard, got %q", functionCalls[0].Name) + } + if got := functionCalls[0].Args["location"]; got != "New York" { + t.Errorf("expected location \"New York\", got %v", got) + } +} diff --git a/go/core/internal/httpserver/handlers/handlers.go b/go/core/internal/httpserver/handlers/handlers.go index d96d9ee26b..083c5cf1a2 100644 --- a/go/core/internal/httpserver/handlers/handlers.go +++ b/go/core/internal/httpserver/handlers/handlers.go @@ -27,6 +27,7 @@ type Handlers struct { Agents *AgentsHandler Tools *ToolsHandler ToolServers *ToolServersHandler + MCPApps *MCPAppsHandler ToolServerTypes *ToolServerTypesHandler Memory *MemoryHandler Feedback *FeedbackHandler @@ -90,6 +91,7 @@ func NewHandlers( Agents: NewAgentsHandler(base), Tools: NewToolsHandler(base), ToolServers: NewToolServersHandler(base), + MCPApps: NewMCPAppsHandler(base), ToolServerTypes: NewToolServerTypesHandler(base), Memory: NewMemoryHandler(base), Feedback: NewFeedbackHandler(base), diff --git a/go/core/internal/httpserver/handlers/mcpapps.go b/go/core/internal/httpserver/handlers/mcpapps.go new file mode 100644 index 0000000000..017cbc1ec4 --- /dev/null +++ b/go/core/internal/httpserver/handlers/mcpapps.go @@ -0,0 +1,323 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + api "github.com/kagent-dev/kagent/go/api/httpapi" + "github.com/kagent-dev/kagent/go/api/v1alpha2" + agent_translator "github.com/kagent-dev/kagent/go/core/internal/controller/translator/agent" + "github.com/kagent-dev/kagent/go/core/internal/httpserver/errors" + "github.com/kagent-dev/kagent/go/core/internal/version" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + kmcp "github.com/kagent-dev/kmcp/api/v1alpha1" + "github.com/modelcontextprotocol/go-sdk/mcp" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +const mcpAppHTMLMimeType = "text/html;profile=mcp-app" + +type MCPAppsHandler struct { + *Base +} + +type MCPAppToolResponse struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema any `json:"inputSchema,omitempty"` + UIResourceURI string `json:"uiResourceUri,omitempty"` + Meta map[string]any `json:"_meta,omitempty"` +} + +type mcpAppToolCallRequest struct { + Arguments any `json:"arguments,omitempty"` +} + +func NewMCPAppsHandler(base *Base) *MCPAppsHandler { + return &MCPAppsHandler{Base: base} +} + +func (h *MCPAppsHandler) HandleListTools(w ErrorResponseWriter, r *http.Request) { + namespace, name, ok := h.remoteMCPServerRef(w, r) + if !ok { + return + } + + session, cancel, err := h.connect(r.Context(), namespace, name) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to connect to MCP server", err)) + return + } + defer cancel() + defer session.Close() + + result, err := session.ListTools(r.Context(), &mcp.ListToolsParams{}) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to list MCP tools", err)) + return + } + + tools := make([]MCPAppToolResponse, 0, len(result.Tools)) + for _, tool := range result.Tools { + if tool == nil { + continue + } + uiResourceURI, _ := extractUIResourceURI(tool.Meta) + tools = append(tools, MCPAppToolResponse{ + Name: tool.Name, + Description: tool.Description, + InputSchema: tool.InputSchema, + UIResourceURI: uiResourceURI, + Meta: tool.Meta, + }) + } + + RespondWithJSON(w, http.StatusOK, api.NewResponse(tools, "Successfully listed MCP app tools", false)) +} + +func (h *MCPAppsHandler) HandleCallTool(w ErrorResponseWriter, r *http.Request) { + namespace, name, ok := h.remoteMCPServerRef(w, r) + if !ok { + return + } + toolName, err := GetPathParam(r, "toolName") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get tool name from path", err)) + return + } + + var req mcpAppToolCallRequest + if r.Body != nil { + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to read request body", readErr)) + return + } + if len(strings.TrimSpace(string(body))) > 0 { + if err := json.Unmarshal(body, &req); err != nil { + w.RespondWithError(errors.NewBadRequestError("Invalid request body", err)) + return + } + } + } + + session, cancel, err := h.connect(r.Context(), namespace, name) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to connect to MCP server", err)) + return + } + defer cancel() + defer session.Close() + + result, err := session.CallTool(r.Context(), &mcp.CallToolParams{ + Name: toolName, + Arguments: req.Arguments, + }) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to call MCP tool", err)) + return + } + + RespondWithJSON(w, http.StatusOK, api.NewResponse(result, "Successfully called MCP tool", false)) +} + +func (h *MCPAppsHandler) HandleReadResource(w ErrorResponseWriter, r *http.Request) { + namespace, name, ok := h.remoteMCPServerRef(w, r) + if !ok { + return + } + uri := r.URL.Query().Get("uri") + if uri == "" { + w.RespondWithError(errors.NewBadRequestError("Missing required uri query parameter", nil)) + return + } + if !strings.HasPrefix(uri, "ui://") { + w.RespondWithError(errors.NewBadRequestError("MCP Apps resources must use ui:// URIs", nil)) + return + } + + session, cancel, err := h.connect(r.Context(), namespace, name) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to connect to MCP server", err)) + return + } + defer cancel() + defer session.Close() + + result, err := session.ReadResource(r.Context(), &mcp.ReadResourceParams{URI: uri}) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to read MCP resource", err)) + return + } + if err := validateMCPAppResource(result); err != nil { + w.RespondWithError(errors.NewValidationError("Invalid MCP Apps resource", err)) + return + } + + RespondWithJSON(w, http.StatusOK, api.NewResponse(result, "Successfully read MCP app resource", false)) +} + +func (h *MCPAppsHandler) remoteMCPServerRef(w ErrorResponseWriter, r *http.Request) (string, string, bool) { + namespace, err := GetPathParam(r, "namespace") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get namespace from path", err)) + return "", "", false + } + name, err := GetPathParam(r, "name") + if err != nil { + w.RespondWithError(errors.NewBadRequestError("Failed to get name from path", err)) + return "", "", false + } + if err := Check(h.Authorizer, r, auth.Resource{Type: "ToolServer", Name: types.NamespacedName{Namespace: namespace, Name: name}.String()}); err != nil { + w.RespondWithError(err) + return "", "", false + } + return namespace, name, true +} + +// resolveRemoteMCPServer locates the MCP endpoint for the given ref, supporting +// both RemoteMCPServer (external URL) and the kmcp MCPServer CRD (an in-cluster +// Deployment+Service). An MCPServer is converted to the same RemoteMCPServer +// shape the controller uses for tool discovery, so both kinds share one connect +// path. +func (h *MCPAppsHandler) resolveRemoteMCPServer(ctx context.Context, namespace, name string) (*v1alpha2.RemoteMCPServer, error) { + key := client.ObjectKey{Namespace: namespace, Name: name} + + server := &v1alpha2.RemoteMCPServer{} + err := h.KubeClient.Get(ctx, key, server) + if err == nil { + return server, nil + } + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get RemoteMCPServer %s/%s: %w", namespace, name, err) + } + + mcpServer := &kmcp.MCPServer{} + if err := h.KubeClient.Get(ctx, key, mcpServer); err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf("no RemoteMCPServer or MCPServer %s/%s found", namespace, name) + } + return nil, fmt.Errorf("failed to get MCPServer %s/%s: %w", namespace, name, err) + } + server, err = agent_translator.ConvertMCPServerToRemoteMCPServer(mcpServer) + if err != nil { + return nil, fmt.Errorf("failed to resolve MCPServer %s/%s endpoint: %w", namespace, name, err) + } + return server, nil +} + +func (h *MCPAppsHandler) connect(ctx context.Context, namespace, name string) (*mcp.ClientSession, context.CancelFunc, error) { + log := ctrllog.FromContext(ctx).WithName("mcp-apps-handler").WithValues("namespace", namespace, "name", name) + + server, err := h.resolveRemoteMCPServer(ctx, namespace, name) + if err != nil { + return nil, nil, err + } + + timeout := 30 * time.Second + if server.Spec.Timeout != nil && server.Spec.Timeout.Duration > 0 { + timeout = server.Spec.Timeout.Duration + } + connectCtx, cancel := context.WithTimeout(ctx, timeout) + + headers, err := server.ResolveHeaders(connectCtx, h.KubeClient) + if err != nil { + cancel() + return nil, nil, fmt.Errorf("failed to resolve RemoteMCPServer headers: %w", err) + } + + httpClient := newMCPAppsHTTPClient(headers) + var transport mcp.Transport + switch server.Spec.Protocol { + case v1alpha2.RemoteMCPServerProtocolSse: + transport = &mcp.SSEClientTransport{ + Endpoint: server.Spec.URL, + HTTPClient: httpClient, + } + default: + transport = &mcp.StreamableClientTransport{ + Endpoint: server.Spec.URL, + HTTPClient: httpClient, + } + } + + impl := &mcp.Implementation{ + Name: "kagent-controller", + Version: version.Version, + } + client := mcp.NewClient(impl, nil) + session, err := client.Connect(connectCtx, transport, nil) + if err != nil { + cancel() + return nil, nil, fmt.Errorf("failed to connect MCP client: %w", err) + } + + log.V(2).Info("Connected to MCP server for MCP Apps") + return session, cancel, nil +} + +func extractUIResourceURI(meta map[string]any) (string, bool) { + if len(meta) == 0 { + return "", false + } + if ui, ok := meta["ui"].(map[string]any); ok { + if uri, ok := ui["resourceUri"].(string); ok && uri != "" { + return uri, true + } + } + if uri, ok := meta["ui/resourceUri"].(string); ok && uri != "" { + return uri, true + } + return "", false +} + +func validateMCPAppResource(result *mcp.ReadResourceResult) error { + if result == nil || len(result.Contents) == 0 { + return fmt.Errorf("resource read returned no contents") + } + for _, content := range result.Contents { + if content == nil { + return fmt.Errorf("resource read returned empty content") + } + if content.MIMEType != mcpAppHTMLMimeType { + return fmt.Errorf("resource %s has MIME type %q, expected %q", content.URI, content.MIMEType, mcpAppHTMLMimeType) + } + } + return nil +} + +func newMCPAppsHTTPClient(headers map[string]string) *http.Client { + if len(headers) == 0 { + return http.DefaultClient + } + return &http.Client{ + Transport: &mcpAppsHeaderTransport{ + headers: headers, + base: http.DefaultTransport, + }, + } +} + +type mcpAppsHeaderTransport struct { + headers map[string]string + base http.RoundTripper +} + +func (t *mcpAppsHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + for k, v := range t.headers { + req.Header.Set(k, v) + } + if t.base == nil { + t.base = http.DefaultTransport + } + return t.base.RoundTrip(req) +} diff --git a/go/core/internal/httpserver/handlers/mcpapps_test.go b/go/core/internal/httpserver/handlers/mcpapps_test.go new file mode 100644 index 0000000000..70d165c9fc --- /dev/null +++ b/go/core/internal/httpserver/handlers/mcpapps_test.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "context" + "strings" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + kmcp "github.com/kagent-dev/kmcp/api/v1alpha1" + 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" +) + +// TestResolveRemoteMCPServer pins the dual-CRD resolution: a RemoteMCPServer is +// used as-is, a kmcp MCPServer is converted to the in-cluster Service URL, and a +// missing ref returns a clear error instead of leaking RemoteMCPServer specifics. +func TestResolveRemoteMCPServer(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha2.AddToScheme(scheme); err != nil { + t.Fatalf("add v1alpha2 to scheme: %v", err) + } + if err := kmcp.AddToScheme(scheme); err != nil { + t.Fatalf("add kmcp to scheme: %v", err) + } + + remote := &v1alpha2.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "remote", Namespace: "default"}, + Spec: v1alpha2.RemoteMCPServerSpec{ + URL: "https://example.com/mcp", + Protocol: v1alpha2.RemoteMCPServerProtocolStreamableHttp, + }, + } + mcpServer := &kmcp.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "team"}, + } + mcpServer.Spec.Deployment.Port = 8080 + + tests := []struct { + name string + objects []client.Object + namespace string + server string + wantURL string + wantErr string + }{ + { + name: "RemoteMCPServer used directly", + objects: []client.Object{remote}, + namespace: "default", + server: "remote", + wantURL: "https://example.com/mcp", + }, + { + name: "falls back to kmcp MCPServer service URL", + objects: []client.Object{mcpServer}, + namespace: "team", + server: "local", + wantURL: "http://local.team:8080/mcp", + }, + { + name: "neither CRD exists", + objects: nil, + namespace: "default", + server: "missing", + wantErr: "no RemoteMCPServer or MCPServer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.objects...).Build() + h := &MCPAppsHandler{Base: &Base{KubeClient: kubeClient}} + + got, err := h.resolveRemoteMCPServer(context.Background(), tt.namespace, tt.server) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("resolveRemoteMCPServer() error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolveRemoteMCPServer() unexpected error: %v", err) + } + if got.Spec.URL != tt.wantURL { + t.Errorf("resolveRemoteMCPServer() URL = %q, want %q", got.Spec.URL, tt.wantURL) + } + }) + } +} diff --git a/go/core/internal/httpserver/server.go b/go/core/internal/httpserver/server.go index 427cfdbca3..f0002c8758 100644 --- a/go/core/internal/httpserver/server.go +++ b/go/core/internal/httpserver/server.go @@ -35,6 +35,7 @@ const ( APIPathTasks = "/api/tasks" APIPathTools = "/api/tools" APIPathToolServers = "/api/toolservers" + APIPathMCPApps = "/api/mcp-apps" APIPathToolServerTypes = "/api/toolservertypes" APIPathAgents = "/api/agents" APIPathSandboxAgents = "/api/sandboxagents" @@ -261,6 +262,11 @@ func (s *HTTPServer) setupRoutes() { s.router.HandleFunc(APIPathToolServers, adaptHandler(s.handlers.ToolServers.HandleCreateToolServer)).Methods(http.MethodPost) s.router.HandleFunc(APIPathToolServers+"/{namespace}/{name}", adaptHandler(s.handlers.ToolServers.HandleDeleteToolServer)).Methods(http.MethodDelete) + // MCP Apps + s.router.HandleFunc(APIPathMCPApps+"/{namespace}/{name}/tools", adaptHandler(s.handlers.MCPApps.HandleListTools)).Methods(http.MethodGet) + s.router.HandleFunc(APIPathMCPApps+"/{namespace}/{name}/tools/{toolName}/call", adaptHandler(s.handlers.MCPApps.HandleCallTool)).Methods(http.MethodPost) + s.router.HandleFunc(APIPathMCPApps+"/{namespace}/{name}/resources", adaptHandler(s.handlers.MCPApps.HandleReadResource)).Methods(http.MethodGet) + // Tool Server Types s.router.HandleFunc(APIPathToolServerTypes, adaptHandler(s.handlers.ToolServerTypes.HandleListToolServerTypes)).Methods(http.MethodGet) diff --git a/python/packages/kagent-adk/src/kagent/adk/_mcp_apps.py b/python/packages/kagent-adk/src/kagent/adk/_mcp_apps.py new file mode 100644 index 0000000000..4ae1f62885 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/_mcp_apps.py @@ -0,0 +1,96 @@ +"""MCP App (UI) tool result compaction for the model. + +Mirrors the Go ADK behavior in ``go/adk/pkg/agent/mcp_apps.go``. An MCP App tool +(one declaring a ``ui.resourceUri``) renders an interactive widget in the chat +that updates itself in place. 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 (observed +with weaker models calling the same render tool 5-7 times in a row). + +We keep the full tool payload in the session/chat history so the UI can render +the widget, and only rewrite what the *model* sees into a short terminal +directive. ADK builds the model request from deep copies of the session events +(see ``flows/llm_flows/contents.py``), so mutating the request here does not +corrupt the stored history. +""" + +from __future__ import annotations + +from google.adk.agents.callback_context import CallbackContext +from google.adk.models.llm_request import LlmRequest + +# Terminal directive the model sees in place of an MCP App tool's render +# payload. It is protocol-oriented: it applies to any tool carrying a UI +# resourceUri, independent of the tool's name or payload keys. +MCP_APP_RENDERED_NOTICE = ( + "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." +) + + +class MCPAppToolNames: + """Mutable, shared set of MCP App (UI-rendering) tool names. + + Populated lazily by ``KAgentMcpToolset.get_tools`` as MCP tools are resolved + (which happens during request preprocessing, before ``before_model_callback`` + runs) and read by the compaction callback. Using a shared object avoids + re-listing MCP tools on every model turn. + """ + + def __init__(self) -> None: + self._names: set[str] = set() + + def add(self, name: str) -> None: + self._names.add(name) + + def __contains__(self, name: str) -> bool: + return name in self._names + + def __bool__(self) -> bool: + return bool(self._names) + + def __len__(self) -> int: + return len(self._names) + + +def compact_mcp_app_response(response: dict) -> dict: + """Rewrite an MCP App tool result (a JSON ``CallToolResult``) for the model. + + On error, keep the content so the model can diagnose/recover but drop the + heavy structured payload. On success, collapse the render payload into a + terminal directive so the model stops re-invoking the rendering tool, + preserving ``_meta`` (e.g. resourceUri) for any downstream tooling. + """ + if response.get("isError") is True or response.get("error") is True: + compacted = dict(response) + compacted.pop("structuredContent", None) + return compacted + + compacted: dict = {"content": [{"type": "text", "text": MCP_APP_RENDERED_NOTICE}]} + if "_meta" in response: + compacted["_meta"] = response["_meta"] + return compacted + + +def make_mcp_app_model_result_callback(app_tool_names: MCPAppToolNames): + """Build a ``before_model_callback`` that compacts MCP App tool results. + + Only the model's view is changed; the full result remains in chat history + for UI rendering. The callback is a no-op until ``app_tool_names`` has been + populated, so it is safe to attach unconditionally. + """ + + def before_model(callback_context: CallbackContext, llm_request: LlmRequest) -> None: + if not app_tool_names or not llm_request.contents: + return None + for content in llm_request.contents: + for part in content.parts or []: + function_response = part.function_response + if function_response is None or function_response.name not in app_tool_names: + continue + if isinstance(function_response.response, dict): + function_response.response = compact_mcp_app_response(function_response.response) + return None + + return before_model diff --git a/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py b/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py index 817f5eb9e5..b4fe314567 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py +++ b/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py @@ -11,6 +11,8 @@ from google.adk.tools.tool_context import ToolContext from mcp.shared.exceptions import McpError +from kagent.adk._mcp_apps import MCPAppToolNames + logger = logging.getLogger("kagent_adk." + __name__) # Connection errors that indicate an unreachable MCP server. @@ -126,8 +128,21 @@ class KAgentMcpToolset(McpToolset): This is particularly useful for explicitly catching and enriching failures that the base implementation may not catch and propagate without enough context. + + When an ``app_tool_names`` registry is supplied, the names of MCP App + (UI-rendering) tools are recorded into it as tools are resolved, so the + agent's before-model callback can compact their results for the model + (see ``_mcp_apps.make_mcp_app_model_result_callback``). """ + # Class-level default so instances created via __new__ (e.g. in tests that + # bypass __init__) still resolve the attribute. + _app_tool_names: Optional[MCPAppToolNames] = None + + def __init__(self, *args: Any, app_tool_names: Optional[MCPAppToolNames] = None, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._app_tool_names = app_tool_names + async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> list[BaseTool]: try: tools = await super().get_tools(readonly_context) @@ -138,10 +153,15 @@ async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> # errors are returned as error text instead of raised. wrapped_tools: list[BaseTool] = [] for tool in tools: - if isinstance(tool, McpTool) and not isinstance(tool, ConnectionSafeMcpTool): - wrapped_tools.append(ConnectionSafeMcpTool(tool)) - else: - wrapped_tools.append(tool) + if isinstance(tool, McpTool): + # getattr guards against partially-constructed McpTool stubs + # whose mcp_app_resource_uri property would raise. + if self._app_tool_names is not None and getattr(tool, "mcp_app_resource_uri", None): + self._app_tool_names.add(tool.name) + if not isinstance(tool, ConnectionSafeMcpTool): + wrapped_tools.append(ConnectionSafeMcpTool(tool)) + continue + wrapped_tools.append(tool) return wrapped_tools async def close(self) -> None: diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index be33f62214..ee378109db 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -14,8 +14,8 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator from kagent.adk._approval import make_approval_callback, strip_confirmation_parts_callback +from kagent.adk._mcp_apps import MCPAppToolNames, make_mcp_app_model_result_callback from kagent.adk._mcp_toolset import KAgentMcpToolset -from kagent.adk.models._ssl import create_ssl_context from kagent.adk._remote_a2a_tool import KAgentRemoteA2AToolset from kagent.adk.models._anthropic import KAgentAnthropicLlm from kagent.adk.models._bedrock import KAgentBedrockLlm @@ -23,6 +23,7 @@ from kagent.adk.models._ollama import create_ollama_llm from kagent.adk.models._openai import AzureOpenAI as OpenAIAzure from kagent.adk.models._openai import OpenAI as OpenAINative +from kagent.adk.models._ssl import create_ssl_context from kagent.adk.sandbox_code_executer import SandboxedLocalCodeExecutor from kagent.adk.tools.ask_user_tool import AskUserTool @@ -391,6 +392,9 @@ def to_agent( raise ValueError("Agent name must be a non-empty string.") tools: list[ToolUnion] = [] tools_requiring_approval: set[str] = set() + # Names of MCP App (UI-rendering) tools, filled in lazily as MCP tools + # are resolved; used to compact their results for the model. + mcp_app_tool_names = MCPAppToolNames() sts_header_provider = None if sts_integration: sts_header_provider = sts_integration.header_provider @@ -410,6 +414,7 @@ def to_agent( connection_params=http_tool.params, tool_filter=http_tool.tools, header_provider=tool_header_provider, + app_tool_names=mcp_app_tool_names, ) ) if http_tool.require_approval: @@ -427,6 +432,7 @@ def to_agent( connection_params=sse_tool.params, tool_filter=sse_tool.tools, header_provider=tool_header_provider, + app_tool_names=mcp_app_tool_names, ) ) if sse_tool.require_approval: @@ -514,7 +520,13 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: # Build before_tool_callback if any tools require approval before_tool_callback = make_approval_callback(tools_requiring_approval) if tools_requiring_approval else None - before_model_callback = strip_confirmation_parts_callback if tools_requiring_approval else None + # before_model callbacks run in order. Strip synthetic HITL confirmation + # parts (when approval is in play), then compact MCP App tool results so + # the model treats a rendered widget as terminal instead of re-calling it. + before_model_callbacks = [] + if tools_requiring_approval: + before_model_callbacks.append(strip_confirmation_parts_callback) + before_model_callbacks.append(make_mcp_app_model_result_callback(mcp_app_tool_names)) # static_instruction is sent directly to the model without any placeholder processing agent = Agent( @@ -525,7 +537,7 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: tools=tools, code_executor=code_executor, before_tool_callback=before_tool_callback, - before_model_callback=before_model_callback, + before_model_callback=before_model_callbacks, ) # Configure memory if enabled diff --git a/python/packages/kagent-adk/tests/unittests/test_mcp_apps.py b/python/packages/kagent-adk/tests/unittests/test_mcp_apps.py new file mode 100644 index 0000000000..8b0db4298a --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_mcp_apps.py @@ -0,0 +1,75 @@ +"""Tests for MCP App (UI) tool result compaction for the model.""" + +from google.adk.models.llm_request import LlmRequest +from google.genai import types as genai_types + +from kagent.adk._mcp_apps import ( + MCP_APP_RENDERED_NOTICE, + MCPAppToolNames, + compact_mcp_app_response, + make_mcp_app_model_result_callback, +) + + +def _function_response_content(name: str, response: dict) -> genai_types.Content: + return genai_types.Content( + role="user", + parts=[genai_types.Part(function_response=genai_types.FunctionResponse(name=name, response=response))], + ) + + +def test_compact_success_replaces_payload_with_notice(): + response = { + "content": [{"type": "text", "text": "Weather for Chicago: Rain, 36C, 82%."}], + "structuredContent": {"temperature": 36, "conditions": "Rain", "humidity": 82}, + "_meta": {"ui": {"resourceUri": "ui://server-everything/weather-dashboard"}}, + } + compacted = compact_mcp_app_response(response) + assert compacted["content"] == [{"type": "text", "text": MCP_APP_RENDERED_NOTICE}] + assert "structuredContent" not in compacted + # _meta is preserved for downstream tooling. + assert compacted["_meta"] == response["_meta"] + + +def test_compact_error_keeps_content_drops_structured(): + response = { + "content": [{"type": "text", "text": "boom"}], + "structuredContent": {"x": 1}, + "isError": True, + } + compacted = compact_mcp_app_response(response) + assert compacted["content"] == [{"type": "text", "text": "boom"}] + assert "structuredContent" not in compacted + assert compacted["isError"] is True + + +def test_callback_compacts_only_app_tool_responses(): + app_tool_names = MCPAppToolNames() + app_tool_names.add("show-weather-dashboard") + callback = make_mcp_app_model_result_callback(app_tool_names) + + weather = {"structuredContent": {"temperature": 36}, "content": [{"type": "text", "text": "36C"}]} + echo = {"content": [{"type": "text", "text": "hello"}]} + request = LlmRequest( + contents=[ + _function_response_content("show-weather-dashboard", weather), + _function_response_content("echo", echo), + ] + ) + + callback(callback_context=None, llm_request=request) + + app_resp = request.contents[0].parts[0].function_response.response + other_resp = request.contents[1].parts[0].function_response.response + assert app_resp["content"] == [{"type": "text", "text": MCP_APP_RENDERED_NOTICE}] + assert "structuredContent" not in app_resp + # Non-app tool results are left untouched. + assert other_resp == echo + + +def test_callback_is_noop_when_no_app_tools(): + callback = make_mcp_app_model_result_callback(MCPAppToolNames()) + weather = {"structuredContent": {"temperature": 36}} + request = LlmRequest(contents=[_function_response_content("show-weather-dashboard", weather)]) + callback(callback_context=None, llm_request=request) + assert request.contents[0].parts[0].function_response.response == weather diff --git a/ui/package-lock.json b/ui/package-lock.json index 545766788a..485e7c42e4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "@a2a-js/sdk": "^0.3.13", "@hookform/resolvers": "^5.4.0", + "@mcp-ui/client": "^7.1.1", + "@modelcontextprotocol/ext-apps": "^1.7.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-alert-dialog": "^1.1.17", "@radix-ui/react-checkbox": "^1.3.5", @@ -1652,6 +1655,18 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@hookform/resolvers": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", @@ -3038,6 +3053,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mcp-ui/client": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-7.1.1.tgz", + "integrity": "sha512-Yy0q3YFl6WmcHRW0pRwD2F+Fs9Y/TFm1xpBpkuqvS1IRBaGGgc7PvkB0nvrKTqO6QZm+MufObfQM+oxo8mmLFw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.2.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^3.23.8" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, + "node_modules/@mcp-ui/client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", @@ -3055,6 +3094,97 @@ "react": ">=16" } }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", + "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -5414,7 +5544,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -7101,6 +7230,44 @@ "addons/*" ] }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -7164,6 +7331,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -7901,6 +8107,75 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -8028,6 +8303,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -8061,7 +8345,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8075,7 +8358,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8646,6 +8928,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -8667,6 +8971,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -8674,6 +8987,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -8685,7 +9015,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -9082,6 +9411,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -9177,7 +9515,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -9206,6 +9543,12 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.340", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", @@ -9243,6 +9586,15 @@ "node": ">=14" } }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9362,7 +9714,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9372,7 +9723,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9416,7 +9766,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9523,6 +9872,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -10021,6 +10376,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", @@ -10035,6 +10399,27 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -10109,6 +10494,101 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -10129,7 +10609,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -10193,6 +10672,22 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", @@ -10264,6 +10759,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10396,6 +10912,15 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -10410,6 +10935,15 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -10534,7 +11068,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -10578,7 +11111,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10762,7 +11294,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10866,7 +11397,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11001,6 +11531,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -11031,6 +11570,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -11214,7 +11773,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -11248,6 +11806,24 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-absolute-url": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", @@ -11695,6 +12271,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11899,7 +12481,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isstream": { @@ -13213,6 +13794,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -14085,7 +14672,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14373,6 +14959,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -15252,6 +15859,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -15434,7 +16050,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15553,11 +16168,22 @@ "https://opencollective.com/debug" ] }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -15836,6 +16462,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -15860,7 +16495,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -15968,6 +16602,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -16364,6 +17007,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -16413,7 +17069,6 @@ "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -16445,6 +17100,46 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -16871,6 +17566,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -17048,6 +17752,32 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -17180,7 +17910,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -17212,6 +17941,76 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -17268,6 +18067,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -17330,7 +18135,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -17343,22 +18147,20 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -17370,14 +18172,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -17390,7 +18191,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -17409,7 +18209,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -17703,7 +18502,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -18495,6 +19293,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -18835,6 +19642,62 @@ "node": ">=8" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -19088,6 +19951,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unplugin": { "version": "2.3.11", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", @@ -19292,6 +20164,15 @@ "node": ">=10.12.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -19679,7 +20560,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -19914,7 +20794,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -20089,6 +20968,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index aeda2e3a43..e4cd399a67 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,9 @@ "dependencies": { "@a2a-js/sdk": "^0.3.13", "@hookform/resolvers": "^5.4.0", + "@mcp-ui/client": "^7.1.1", + "@modelcontextprotocol/ext-apps": "^1.7.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@radix-ui/react-accordion": "^1.2.14", "@radix-ui/react-alert-dialog": "^1.1.17", "@radix-ui/react-checkbox": "^1.3.5", diff --git a/ui/public/mockServiceWorker.js b/ui/public/mockServiceWorker.js index a1e52b4778..33dde9e770 100644 --- a/ui/public/mockServiceWorker.js +++ b/ui/public/mockServiceWorker.js @@ -7,7 +7,7 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = '2.14.2' +const PACKAGE_VERSION = '2.14.6' const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() diff --git a/ui/public/sandbox_proxy.html b/ui/public/sandbox_proxy.html new file mode 100644 index 0000000000..db97464939 --- /dev/null +++ b/ui/public/sandbox_proxy.html @@ -0,0 +1,48 @@ + + + + + MCP Apps Sandbox Proxy + + + + + + diff --git a/ui/src/app/actions/__tests__/mcp-apps.test.ts b/ui/src/app/actions/__tests__/mcp-apps.test.ts new file mode 100644 index 0000000000..d2734e2c9e --- /dev/null +++ b/ui/src/app/actions/__tests__/mcp-apps.test.ts @@ -0,0 +1,77 @@ +import { + listMcpAppTools, + callMcpAppTool, + readMcpAppResource, +} from "@/app/actions/mcp-apps"; +import { fetchApi } from "@/app/actions/utils"; + +jest.mock("@/app/actions/utils", () => ({ + fetchApi: jest.fn(), + createErrorResponse: jest.fn((err: unknown, message: string) => ({ + error: true, + message, + })), +})); + +const mockedFetchApi = fetchApi as jest.Mock; + +describe("mcp-apps server actions", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedFetchApi.mockResolvedValue({ error: false, data: [] }); + }); + + it("lists tools for the namespaced server", async () => { + await listMcpAppTools("kagent", "kanban-mcp"); + + expect(mockedFetchApi).toHaveBeenCalledWith("/mcp-apps/kagent/kanban-mcp/tools"); + }); + + it("URL-encodes namespace and server names", async () => { + await listMcpAppTools("my ns", "weird/name"); + + expect(mockedFetchApi).toHaveBeenCalledWith( + "/mcp-apps/my%20ns/weird%2Fname/tools" + ); + }); + + it("POSTs tool calls with a JSON arguments body", async () => { + await callMcpAppTool("kagent", "kanban-mcp", "move_task", { id: "t1", to: "done" }); + + expect(mockedFetchApi).toHaveBeenCalledWith( + "/mcp-apps/kagent/kanban-mcp/tools/move_task/call", + { + method: "POST", + body: JSON.stringify({ arguments: { id: "t1", to: "done" } }), + } + ); + }); + + it("defaults tool-call arguments to an empty object", async () => { + await callMcpAppTool("kagent", "kanban-mcp", "refresh"); + + expect(mockedFetchApi).toHaveBeenCalledWith( + "/mcp-apps/kagent/kanban-mcp/tools/refresh/call", + { + method: "POST", + body: JSON.stringify({ arguments: {} }), + } + ); + }); + + it("reads a resource by URI (encoded)", async () => { + await readMcpAppResource("kagent", "kanban-mcp", "ui://board?x=1"); + + expect(mockedFetchApi).toHaveBeenCalledWith( + "/mcp-apps/kagent/kanban-mcp/resources?uri=ui%3A%2F%2Fboard%3Fx%3D1" + ); + }); + + it("returns an error response when fetchApi throws", async () => { + mockedFetchApi.mockRejectedValueOnce(new Error("boom")); + + const result = await listMcpAppTools("kagent", "kanban-mcp"); + + expect(result).toEqual({ error: true, message: "Failed to list MCP app tools" }); + }); +}); diff --git a/ui/src/app/actions/mcp-apps.ts b/ui/src/app/actions/mcp-apps.ts new file mode 100644 index 0000000000..a62afd723c --- /dev/null +++ b/ui/src/app/actions/mcp-apps.ts @@ -0,0 +1,58 @@ +"use server"; + +import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; +import type { BaseResponse } from "@/types"; +import { createErrorResponse, fetchApi } from "./utils"; + +export interface McpAppTool { + name: string; + description?: string; + inputSchema?: unknown; + uiResourceUri?: string; + _meta?: Record; +} + +function serverPath(namespace: string, name: string): string { + return `/mcp-apps/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`; +} + +export async function listMcpAppTools(namespace: string, name: string): Promise> { + try { + return await fetchApi>(`${serverPath(namespace, name)}/tools`); + } catch (err) { + return createErrorResponse(err, "Failed to list MCP app tools"); + } +} + +export async function callMcpAppTool( + namespace: string, + name: string, + toolName: string, + args?: Record, +): Promise> { + try { + return await fetchApi>( + `${serverPath(namespace, name)}/tools/${encodeURIComponent(toolName)}/call`, + { + method: "POST", + body: JSON.stringify({ arguments: args ?? {} }), + }, + ); + } catch (err) { + return createErrorResponse(err, "Failed to call MCP app tool"); + } +} + +export async function readMcpAppResource( + namespace: string, + name: string, + uri: string, +): Promise> { + try { + return await fetchApi>( + `${serverPath(namespace, name)}/resources?uri=${encodeURIComponent(uri)}`, + ); + } catch (err) { + return createErrorResponse(err, "Failed to read MCP app resource"); + } +} diff --git a/ui/src/components/ToolDisplay.tsx b/ui/src/components/ToolDisplay.tsx index 1fd6ef81a3..4b13ebac0f 100644 --- a/ui/src/components/ToolDisplay.tsx +++ b/ui/src/components/ToolDisplay.tsx @@ -1,12 +1,14 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { FunctionCall, TokenStats } from "@/types"; -import { ScrollArea } from "@radix-ui/react-scroll-area"; import { FunctionSquare, CheckCircle, Clock, Code, ChevronUp, ChevronDown, Loader2, Text, Check, Copy, AlertCircle, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import TokenStatsTooltip from "@/components/chat/TokenStatsTooltip"; import { convertToUserFriendlyName } from "@/lib/utils"; +import { McpAppRenderer } from "@/components/mcp-apps/McpAppRenderer"; +import type { ChatMcpAppTool } from "@/components/chat/ChatMcpAppsContext"; +import { buildMcpAppRenderPayload } from "@/lib/mcpAppToolResult"; export type ToolCallStatus = "requested" | "executing" | "completed" | "pending_approval" | "approved" | "rejected"; @@ -15,6 +17,7 @@ interface ToolDisplayProps { result?: { content: string; is_error?: boolean; + rawResult?: unknown; }; status?: ToolCallStatus; isError?: boolean; @@ -25,9 +28,11 @@ interface ToolDisplayProps { onApprove?: () => void; onReject?: (reason?: string) => void; tokenStats?: TokenStats; + mcpApp?: ChatMcpAppTool; + onMcpAppSendMessage?: (text: string) => Promise; } -const ToolDisplay = ({ call, result, status = "requested", isError = false, isDecided = false, subagentName, onApprove, onReject, tokenStats }: ToolDisplayProps) => { +const ToolDisplay = ({ call, result, status = "requested", isError = false, isDecided = false, subagentName, onApprove, onReject, tokenStats, mcpApp, onMcpAppSendMessage }: ToolDisplayProps) => { const [areArgumentsExpanded, setAreArgumentsExpanded] = useState(status === "pending_approval"); const [areResultsExpanded, setAreResultsExpanded] = useState(false); const [isCopied, setIsCopied] = useState(false); @@ -36,6 +41,13 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe const [rejectionReason, setRejectionReason] = useState(""); const hasResult = result !== undefined; + // Memoized so the {toolInput, toolResult} objects keep a stable identity across + // renders; AppRenderer keys its one-shot tool-input/result delivery off them. + const mcpAppPayload = useMemo( + () => (result ? buildMcpAppRenderPayload(result.rawResult, result.content, call.args) : undefined), + [result, call.args], + ); + const shouldRenderMcpApp = !!mcpApp && !!mcpAppPayload?.toolResult && status === "completed" && !isError; const handleCopy = async () => { try { @@ -154,7 +166,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe : ''; return ( - +
@@ -173,7 +185,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe {getStatusDisplay()}
- +
{areArgumentsExpanded && ( -
- -
-                  {JSON.stringify(call.args, null, 2)}
-                
-
+
+
+                {JSON.stringify(call.args, null, 2)}
+              
)}
@@ -251,7 +261,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe
)} -
+
{status === "executing" && !hasResult && (
@@ -259,26 +269,39 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe
)} {hasResult && ( - <> +
{areResultsExpanded && ( -
- -
+                
+
+
                       {result.content}
                     
- +
)} - +
+ )} + {shouldRenderMcpApp && ( +
+ +
)}
diff --git a/ui/src/components/chat/AskUserDisplay.tsx b/ui/src/components/chat/AskUserDisplay.tsx index 1810246552..f4234b778d 100644 --- a/ui/src/components/chat/AskUserDisplay.tsx +++ b/ui/src/components/chat/AskUserDisplay.tsx @@ -9,10 +9,21 @@ import { cn, convertToUserFriendlyName } from "@/lib/utils"; export interface AskUserQuestion { question: string; - choices?: string[]; + /** Plain strings or `{ key, description }` objects from some agents. */ + choices?: Array; multiple?: boolean; } +function choiceLabel(choice: string | { key?: string; description?: string }): string { + if (typeof choice === "string") return choice; + return choice.description ?? choice.key ?? ""; +} + +function choiceValue(choice: string | { key?: string; description?: string }): string { + if (typeof choice === "string") return choice; + return choice.key ?? choice.description ?? ""; +} + interface AskUserDisplayProps { questions: AskUserQuestion[]; onSubmit: (answers: Array<{ answer: string[] }>) => void; @@ -50,19 +61,19 @@ export default function AskUserDisplay({ ); const [isSubmitting, setIsSubmitting] = useState(false); - const toggleChoice = (qIdx: number, choice: string) => { + const toggleChoice = (qIdx: number, choiceValue: string) => { if (isResolved || isSubmitting) return; setSelectedChoices(prev => { const next = prev.map(s => [...s]); const q = questions[qIdx]; const isMultiple = q.multiple ?? false; - if (next[qIdx].includes(choice)) { - next[qIdx] = next[qIdx].filter(c => c !== choice); + if (next[qIdx].includes(choiceValue)) { + next[qIdx] = next[qIdx].filter(c => c !== choiceValue); } else if (isMultiple) { - next[qIdx] = [...next[qIdx], choice]; + next[qIdx] = [...next[qIdx], choiceValue]; } else { // Single-select: deselect others - next[qIdx] = [choice]; + next[qIdx] = [choiceValue]; } return next; }); @@ -129,16 +140,18 @@ export default function AskUserDisplay({ {/* Choice chips */} {q.choices && q.choices.length > 0 && (
- {q.choices.map((choice) => { + {q.choices.map((choice, choiceIdx) => { + const value = choiceValue(choice); + const label = choiceLabel(choice); const isSelected = isResolved - ? answered.includes(choice) - : selectedChoices[qIdx].includes(choice); + ? answered.includes(value) + : selectedChoices[qIdx].includes(value); return ( ); })} @@ -158,7 +171,7 @@ export default function AskUserDisplay({ {isResolved ? ( /* Resolved: show the free-text portion of the answer (non-chip answers) */ (() => { - const chipAnswers = q.choices ?? []; + const chipAnswers = (q.choices ?? []).map(choiceValue); const freeAnswers = answered.filter(a => !chipAnswers.includes(a)); return freeAnswers.length > 0 ? (

diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 98b828a54d..783624eb91 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -14,6 +14,7 @@ import { useSpeechRecognition } from "@/hooks/useSpeechRecognition"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import ChatMessage from "@/components/chat/ChatMessage"; +import ChatMinimap from "@/components/chat/ChatMinimap"; import StreamingMessage from "./StreamingMessage"; import SessionTokenStatsDisplay from "@/components/chat/TokenStats"; import type { TokenStats, Session, ChatStatus, ToolDecision } from "@/types"; @@ -33,13 +34,13 @@ import { useChatRunInSandbox, useChatSubstrateSandbox } from "@/components/chat/ import { v4 as uuidv4 } from "uuid"; import { getStatusPlaceholder, mapA2AStateToStatus } from "@/lib/statusUtils"; import { Message, DataPart, Task, TaskState } from "@a2a-js/sdk"; +import { useChatMcpApps } from "@/components/chat/ChatMcpAppsContext"; // Task states where the agent is actively processing — resubscribe to live stream. const RESUBSCRIBE_TASK_STATES: TaskState[] = ["submitted", "working"]; // Task states that mean the session is busy (used by the cross-tab send guard). const ACTIVE_TASK_STATES: TaskState[] = ["submitted", "working", "input-required"]; - interface ChatInterfaceProps { selectedAgentName: string; selectedNamespace: string; @@ -49,6 +50,7 @@ interface ChatInterfaceProps { export default function ChatInterface({ selectedAgentName, selectedNamespace, selectedSession, sessionId }: ChatInterfaceProps) { const runInSandbox = useChatRunInSandbox(); + const { getMcpAppForTool } = useChatMcpApps(); const substrateSandbox = useChatSubstrateSandbox(); const router = useRouter(); const containerRef = useRef(null); @@ -223,20 +225,30 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se } }, [storedMessages, streamingMessages, streamingContent]); - - - const handleSendMessage = async (e: React.FormEvent) => { - e.preventDefault(); - if (!currentInputMessage.trim() || !selectedAgentName || !selectedNamespace) { + const sendChatMessageText = async ( + userMessageText: string, + options: { + clearInput?: boolean; + restoreInputOnError?: boolean; + errorLabel?: string; + rethrowOnError?: boolean; + } = {}, + ) => { + if (!userMessageText.trim() || !selectedAgentName || !selectedNamespace) { return; } - - // Stop voice recording if active before sending - if (isListening) { - stopListening(); + if (chatStatus !== "ready") { + const error = new Error("Agent is busy. Try again after the current response finishes."); + toast.error(error.message); + if (options.rethrowOnError) { + throw error; + } + return; } - const userMessageText = currentInputMessage; + if (options.clearInput ?? true) { + setCurrentInputMessage(""); + } // Cross-tab guard: fetch the latest session state before mutating anything. // Two cases: (1) another tab is still streaming — reconnect instead of sending; @@ -307,9 +319,10 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se isCreatingSessionRef.current = true; setIsFirstMessage(true); + const sessionName = deriveSessionTitle(userMessageText); const newSessionResponse = await createSession({ agent_ref: `${selectedNamespace}/${selectedAgentName}`, - name: deriveSessionTitle(userMessageText), + name: sessionName, }); if (newSessionResponse.error || !newSessionResponse.data) { @@ -391,10 +404,39 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se }); } catch (error) { console.error("Error sending message or creating session:", error); - toast.error("Error sending message or creating session"); + toast.error(options.errorLabel || "Error sending message or creating session"); setChatStatus("error"); - setCurrentInputMessage(userMessageText); + if (options.restoreInputOnError ?? true) { + setCurrentInputMessage(userMessageText); + } + if (options.rethrowOnError) { + throw error; + } + } + }; + + const handleSendMessage = async (e: React.FormEvent) => { + e.preventDefault(); + if (isListening) { + stopListening(); + } + if (!currentInputMessage.trim()) { + return; } + + await sendChatMessageText(currentInputMessage); + }; + + // An MCP App pushed a message into the conversation via the ui/message + // channel (e.g. "Build #N triggered, monitor it"). Inject it as a normal user + // turn so the agent can act on it. + const handleMcpAppSendMessage = async (text: string) => { + await sendChatMessageText(text, { + clearInput: false, + restoreInputOnError: false, + errorLabel: "MCP app message failed", + rethrowOnError: true, + }); }; const consumeStream = async (stream: AsyncIterable) => { @@ -590,8 +632,9 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se * HITL mode (expectedTaskId provided): verifies the specific task is still * input-required; resubscribes or reloads if another tab already responded. * - * Send-guard mode (no expectedTaskId): checks for any active task and for - * stale local messages; blocks and syncs if either is detected. + * Send-guard mode (no expectedTaskId): checks for any active task and compares + * the server's message high-water mark against what this tab has synced; blocks + * and reloads if another tab advanced the conversation. */ const checkAndSyncSessionBeforeAction = async ( guardSessionId: string, @@ -948,10 +991,10 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se ); } return ( -

-
+
+
-
+
{/* Never show loading for first message/new session */} {isLoading && sessionId && !isFirstMessage && !isCreatingSessionRef.current ? (
{/* Display stored messages from session */} {storedMessages.map((message, index) => { - return + return
+ +
})} {/* Display streaming messages */} {streamingMessages.map((message, index) => { - return + return
+ +
})} {isStreaming && ( - +
+ +
)} )}
+
-
+
{sessionStats.total > 0 && } diff --git a/ui/src/components/chat/ChatLayoutUI.tsx b/ui/src/components/chat/ChatLayoutUI.tsx index fff1a97f6c..02eac20a29 100644 --- a/ui/src/components/chat/ChatLayoutUI.tsx +++ b/ui/src/components/chat/ChatLayoutUI.tsx @@ -8,6 +8,7 @@ import { getSessionsForAgent } from "@/app/actions/sessions"; import { AgentResponse, Session, RemoteMCPServerResponse, ToolsResponse } from "@/types"; import { toast } from "sonner"; import { ChatAgentProvider } from "@/components/chat/ChatAgentContext"; +import { ChatMcpAppsProvider } from "@/components/chat/ChatMcpAppsContext"; import { isSubstrateSandboxAgent } from "@/lib/sandboxAgentForm"; import { mergeSessionUpdate, normalizeSessionTimestamps } from "@/lib/sessionTimestamps"; @@ -144,15 +145,17 @@ export default function ChatLayoutUI({ acpSessions={acpSessions} isLoadingSessions={isLoadingSessions} /> -
-
- - {children} - +
+
+ + + {children} + +
; + appOnly: boolean; + agentVisible: boolean; +} + +export type ChatMcpAppTool = ChatMcpTool & { uiResourceUri: string }; + +type ChatMcpAppsContextValue = { + getMcpAppForTool: (toolName: string) => ChatMcpAppTool | undefined; + getMcpToolForAppCall: (namespace: string, serverName: string, toolName: string) => ChatMcpTool | undefined; +}; + +const ChatMcpAppsContext = createContext({ + getMcpAppForTool: () => undefined, + getMcpToolForAppCall: () => undefined, +}); + +interface ChatMcpAppsProviderProps { + currentAgent: AgentResponse; + children: ReactNode; +} + +function isRemoteMCPServer(tool: Tool): boolean { + const kind = tool.mcpServer?.kind || "RemoteMCPServer"; + const apiGroup = tool.mcpServer?.apiGroup || "kagent.dev"; + return kind === "RemoteMCPServer" && (apiGroup === "" || apiGroup === "kagent.dev"); +} + +function resolveServerRef(tool: Tool, agentNamespace: string): { namespace: string; name: string } | undefined { + const mcpServer = tool.mcpServer; + if (!mcpServer?.name) { + return undefined; + } + + if (k8sRefUtils.isValidRef(mcpServer.name)) { + return k8sRefUtils.fromRef(mcpServer.name); + } + + return { + namespace: mcpServer.namespace || agentNamespace, + name: mcpServer.name, + }; +} + +function isAppOnlyTool(tool: McpAppTool): boolean { + const ui = tool._meta?.ui; + if (!ui || typeof ui !== "object") { + return false; + } + const visibility = (ui as Record).visibility; + if (typeof visibility === "string") { + return visibility === "app"; + } + if (!Array.isArray(visibility)) { + return false; + } + // app-only means "app" is declared AND "model" is not — if both are + // listed (e.g. ["model", "app"]) the tool is visible to the agent too. + let hasApp = false; + for (const v of visibility) { + if (v === "model") { + return false; + } + if (v === "app") { + hasApp = true; + } + } + return hasApp; +} + +export function ChatMcpAppsProvider({ currentAgent, children }: ChatMcpAppsProviderProps) { + const [appRegistry, setAppRegistry] = useState>(new Map()); + const [toolRegistry, setToolRegistry] = useState>(new Map()); + + const configuredMcpServers = useMemo(() => { + const agentNamespace = currentAgent.agent.metadata.namespace || ""; + const servers = new Map; + selectsAllTools: boolean; + }>(); + + for (const tool of currentAgent.tools || []) { + if (!isMcpTool(tool) || !isRemoteMCPServer(tool)) { + continue; + } + const serverRef = resolveServerRef(tool, agentNamespace); + if (!serverRef) { + continue; + } + + const key = `${serverRef.namespace}/${serverRef.name}`; + const existing = servers.get(key) ?? { + namespace: serverRef.namespace, + name: serverRef.name, + selectedToolNames: new Set(), + selectsAllTools: false, + }; + + const toolNames = tool.mcpServer.toolNames || []; + if (toolNames.length === 0) { + existing.selectsAllTools = true; + } else { + toolNames.forEach((name) => existing.selectedToolNames.add(name)); + } + servers.set(key, existing); + } + + return Array.from(servers.values()); + }, [currentAgent]); + + useEffect(() => { + let cancelled = false; + + async function loadMcpApps() { + if (configuredMcpServers.length === 0) { + setAppRegistry(new Map()); + setToolRegistry(new Map()); + return; + } + + const nextAppRegistry = new Map(); + const nextToolRegistry = new Map(); + const ambiguousToolNames = new Set(); + + await Promise.all(configuredMcpServers.map(async (server) => { + const response = await listMcpAppTools(server.namespace, server.name); + if (cancelled || response.error || !response.data) { + return; + } + + for (const appTool of response.data) { + const appOnly = isAppOnlyTool(appTool); + const selectedForAgent = server.selectsAllTools || server.selectedToolNames.has(appTool.name); + const agentVisible = selectedForAgent && !appOnly; + + const entry: ChatMcpTool = { + namespace: server.namespace, + serverName: server.name, + toolName: appTool.name, + uiResourceUri: appTool.uiResourceUri, + inputSchema: appTool.inputSchema, + meta: appTool._meta, + appOnly, + agentVisible, + }; + + nextToolRegistry.set(toolRegistryKey(server.namespace, server.name, appTool.name), entry); + + if (entry.uiResourceUri && entry.agentVisible) { + const appEntry = entry as ChatMcpAppTool; + const existing = nextAppRegistry.get(appTool.name); + if (existing && (existing.namespace !== appEntry.namespace || existing.serverName !== appEntry.serverName || existing.uiResourceUri !== appEntry.uiResourceUri)) { + ambiguousToolNames.add(appTool.name); + nextAppRegistry.delete(appTool.name); + continue; + } + if (!ambiguousToolNames.has(appTool.name)) { + nextAppRegistry.set(appTool.name, appEntry); + } + } + } + })); + + if (!cancelled) { + setAppRegistry(nextAppRegistry); + setToolRegistry(nextToolRegistry); + } + } + + void loadMcpApps(); + return () => { + cancelled = true; + }; + }, [configuredMcpServers]); + + const value = useMemo(() => ({ + getMcpAppForTool: (toolName: string) => appRegistry.get(toolName), + getMcpToolForAppCall: (namespace: string, serverName: string, toolName: string) => + toolRegistry.get(toolRegistryKey(namespace, serverName, toolName)), + }), [appRegistry, toolRegistry]); + + return ( + + {children} + + ); +} + +export function useChatMcpApps() { + return useContext(ChatMcpAppsContext); +} + +function toolRegistryKey(namespace: string, serverName: string, toolName: string): string { + return `${namespace}/${serverName}/${toolName}`; +} diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index cf71a65b6c..83a6d39e7f 100644 --- a/ui/src/components/chat/ChatMessage.tsx +++ b/ui/src/components/chat/ChatMessage.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner"; import { convertToUserFriendlyName } from "@/lib/utils"; import { ADKMetadata, getMetadataValue } from "@/lib/messageHandlers"; import { ToolDecision } from "@/types"; +import type { ChatMcpAppTool } from "@/components/chat/ChatMcpAppsContext"; interface ChatMessageProps { message: Message; @@ -24,9 +25,11 @@ interface ChatMessageProps { onReject?: (toolCallId: string, reason?: string) => void; onAskUserSubmit?: (answers: Array<{ answer: string[] }>) => void; pendingDecisions?: Record; + getMcpAppForTool?: (toolName: string) => ChatMcpAppTool | undefined; + onMcpAppSendMessage?: (text: string) => Promise; } -export default function ChatMessage({ message, allMessages, agentContext, onApprove, onReject, onAskUserSubmit, pendingDecisions }: ChatMessageProps) { +export default function ChatMessage({ message, allMessages, agentContext, onApprove, onReject, onAskUserSubmit, pendingDecisions, getMcpAppForTool, onMcpAppSendMessage }: ChatMessageProps) { const [feedbackDialogOpen, setFeedbackDialogOpen] = useState(false); const [isPositiveFeedback, setIsPositiveFeedback] = useState(true); @@ -114,6 +117,8 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr onApprove={onApprove} onReject={onReject} pendingDecisions={pendingDecisions} + getMcpAppForTool={getMcpAppForTool} + onMcpAppSendMessage={onMcpAppSendMessage} />; } @@ -124,6 +129,8 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr onApprove={onApprove} onReject={onReject} pendingDecisions={pendingDecisions} + getMcpAppForTool={getMcpAppForTool} + onMcpAppSendMessage={onMcpAppSendMessage} />; } @@ -139,7 +146,7 @@ export default function ChatMessage({ message, allMessages, agentContext, onAppr }); if (hasToolCalls) { - return ; + return ; } return null; } diff --git a/ui/src/components/chat/ChatMinimap.tsx b/ui/src/components/chat/ChatMinimap.tsx new file mode 100644 index 0000000000..267682b0bd --- /dev/null +++ b/ui/src/components/chat/ChatMinimap.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type React from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ChatMinimapProps { + /** Ref to the ScrollArea root that wraps the Radix viewport. */ + containerRef: React.RefObject; + /** + * Bumped by the parent whenever the message list changes, so the minimap + * re-binds its observers/listeners to the (possibly new) viewport. Height + * changes from streaming are picked up by the ResizeObserver regardless. + */ + revision: number; +} + +interface Segment { + topPct: number; + heightPct: number; + role: "user" | "assistant"; +} + +/** + * A scrollbar-style minimap rendered on the right edge of the chat. Each message + * becomes a segment sized proportionally to its height and colored by role; a + * viewport indicator shows the current position. Click or drag the track to jump + * through the history quickly. + */ +export default function ChatMinimap({ containerRef, revision }: ChatMinimapProps) { + const viewportId = useId(); + const [segments, setSegments] = useState([]); + const [view, setView] = useState({ topPct: 0, heightPct: 100 }); + const [scrollable, setScrollable] = useState(false); + const trackRef = useRef(null); + const draggingRef = useRef(false); + + const getViewport = useCallback( + () => (containerRef.current?.querySelector("[data-radix-scroll-area-viewport]") as HTMLElement | null) ?? null, + [containerRef], + ); + + const updateView = useCallback(() => { + const vp = getViewport(); + if (!vp) return; + const total = vp.scrollHeight || 1; + setView({ + topPct: (vp.scrollTop / total) * 100, + heightPct: Math.min(100, (vp.clientHeight / total) * 100), + }); + }, [getViewport]); + + const measure = useCallback(() => { + const vp = getViewport(); + if (!vp) return; + const total = vp.scrollHeight; + if (total <= 0) return; + + const vpRect = vp.getBoundingClientRect(); + const items = Array.from(vp.querySelectorAll("[data-mm-item]")) as HTMLElement[]; + const segs: Segment[] = items.map((el) => { + const r = el.getBoundingClientRect(); + const top = r.top - vpRect.top + vp.scrollTop; + return { + topPct: (top / total) * 100, + heightPct: (r.height / total) * 100, + role: el.getAttribute("data-mm-role") === "user" ? "user" : "assistant", + }; + }); + + setSegments(segs); + setScrollable(total > vp.clientHeight + 4); + updateView(); + }, [getViewport, updateView]); + + useEffect(() => { + const vp = getViewport(); + if (!vp) return; + if (!vp.id) { + vp.id = viewportId; + } + + // Defer the initial measurement so its setState calls don't run + // synchronously inside the effect (which triggers cascading renders). + // The ResizeObserver below also fires on observe(), so layout is captured + // promptly regardless. + const initialMeasure = requestAnimationFrame(() => measure()); + + const onScroll = () => updateView(); + vp.addEventListener("scroll", onScroll, { passive: true }); + + const ro = new ResizeObserver(() => measure()); + ro.observe(vp); + const content = vp.firstElementChild; + if (content) ro.observe(content); + + return () => { + cancelAnimationFrame(initialMeasure); + vp.removeEventListener("scroll", onScroll); + ro.disconnect(); + }; + }, [getViewport, measure, updateView, revision, viewportId]); + + const scrollToClientY = useCallback( + (clientY: number) => { + const vp = getViewport(); + const track = trackRef.current; + if (!vp || !track) return; + const rect = track.getBoundingClientRect(); + const frac = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)); + const total = vp.scrollHeight; + // Center the viewport window on the clicked point for intuitive jumps. + const target = frac * total - vp.clientHeight / 2; + vp.scrollTo({ top: Math.max(0, target), behavior: draggingRef.current ? "auto" : "smooth" }); + }, + [getViewport], + ); + + const onPointerDown = (e: React.PointerEvent) => { + draggingRef.current = true; + e.currentTarget.setPointerCapture?.(e.pointerId); + scrollToClientY(e.clientY); + }; + const onPointerMove = (e: React.PointerEvent) => { + if (!draggingRef.current) return; + scrollToClientY(e.clientY); + }; + const endDrag = (e: React.PointerEvent) => { + draggingRef.current = false; + e.currentTarget.releasePointerCapture?.(e.pointerId); + }; + + if (!scrollable || segments.length === 0) return null; + + return ( +
+
+ {segments.map((s, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/ui/src/components/chat/ToolCallDisplay.tsx b/ui/src/components/chat/ToolCallDisplay.tsx index 9ebaec3e7e..cfe3491bd7 100644 --- a/ui/src/components/chat/ToolCallDisplay.tsx +++ b/ui/src/components/chat/ToolCallDisplay.tsx @@ -5,6 +5,7 @@ import AgentCallDisplay, { AgentCallStatus } from "@/components/chat/AgentCallDi import { isAgentToolName } from "@/lib/utils"; import { ADKMetadata, ProcessedToolCallData, ProcessedToolResultData, ToolResponseData, normalizeToolResultToText, getMetadataValue } from "@/lib/messageHandlers"; import { FunctionCall, ToolDecision, TokenStats } from "@/types"; +import type { ChatMcpAppTool } from "@/components/chat/ChatMcpAppsContext"; interface ToolCallDisplayProps { currentMessage: Message; @@ -12,6 +13,8 @@ interface ToolCallDisplayProps { onApprove?: (toolCallId: string) => void; onReject?: (toolCallId: string, reason?: string) => void; pendingDecisions?: Record; + getMcpAppForTool?: (toolName: string) => ChatMcpAppTool | undefined; + onMcpAppSendMessage?: (text: string) => Promise; } interface ToolCallState { @@ -20,6 +23,7 @@ interface ToolCallState { result?: { content: string; is_error?: boolean; + rawResult?: unknown; }; status: ToolCallStatus; subagentSessionId?: string; @@ -88,7 +92,7 @@ const extractToolCallRequests = (message: Message): FunctionCall[] => { functionCalls.push({ id: data.id, name: data.name, - args: data.args + args: data.args ?? {}, }); } } @@ -146,6 +150,7 @@ const extractToolCallResults = (message: Message): ProcessedToolResultData[] => name: data.name, content: textContent, is_error: data.response?.isError || false, + raw_result: data.response?.result ?? data.response, ...(subagentSessionId ? { subagent_session_id: subagentSessionId } : {}), }); } @@ -172,7 +177,7 @@ const extractToolCallResults = (message: Message): ProcessedToolResultData[] => }; -const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pendingDecisions }: ToolCallDisplayProps) => { +const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pendingDecisions, getMcpAppForTool, onMcpAppSendMessage }: ToolCallDisplayProps) => { // Determine which tool call IDs this component instance "owns" by finding, // for each ID introduced by currentMessage, whether currentMessage is the // FIRST message in allMessages that introduces that ID. @@ -289,7 +294,8 @@ const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pen const existingCall = newToolCalls.get(result.call_id)!; existingCall.result = { content: result.content, - is_error: result.is_error + is_error: result.is_error, + rawResult: result.raw_result, }; if (result.subagent_session_id && !existingCall.subagentSessionId) { // Only set from function_response if the 1st pass (function_call @@ -338,7 +344,7 @@ const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pen const tokenStats = (currentMessage.metadata as Record | undefined)?.tokenStats as TokenStats | undefined; return ( -
+
{currentDisplayableCalls.map(toolCall => { // Determine effective status: use local pending decision for optimistic UI const localDecision = pendingDecisions?.[toolCall.id]; @@ -378,6 +384,8 @@ const ToolCallDisplay = ({ currentMessage, allMessages, onApprove, onReject, pen onApprove={showButtons && onApprove ? () => onApprove(toolCall.id) : undefined} onReject={showButtons && onReject ? (reason?: string) => onReject(toolCall.id, reason) : undefined} tokenStats={tokenStats} + mcpApp={getMcpAppForTool?.(toolCall.call.name)} + onMcpAppSendMessage={onMcpAppSendMessage} /> ); })} diff --git a/ui/src/components/chat/__tests__/ChatLayoutUI.test.tsx b/ui/src/components/chat/__tests__/ChatLayoutUI.test.tsx new file mode 100644 index 0000000000..e52582eefc --- /dev/null +++ b/ui/src/components/chat/__tests__/ChatLayoutUI.test.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import ChatLayoutUI from "@/components/chat/ChatLayoutUI"; +import type { AgentResponse } from "@/types"; + +// Heavy descendants and server actions are stubbed; this test only verifies the +// layout wiring, not their behavior. +jest.mock("@/app/actions/sessions", () => ({ + getSessionsForAgent: jest.fn().mockResolvedValue({ data: [], error: null }), +})); +jest.mock("@/components/sidebars/SessionsSidebar", () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock("@/components/sidebars/AgentDetailsSidebar", () => ({ + AgentDetailsSidebar: () =>
, +})); +jest.mock("@/components/chat/ChatAgentContext", () => ({ + ChatAgentProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Replace the real provider with a marker so we can assert ChatLayoutUI mounts +// it (and wraps the chat children with it). Without this provider the chat +// silently falls back to the empty default context and MCP App widgets never +// render — the regression this test guards against. +jest.mock("@/components/chat/ChatMcpAppsContext", () => ({ + ChatMcpAppsProvider: ({ + currentAgent, + children, + }: { + currentAgent: AgentResponse; + children: React.ReactNode; + }) => ( +
+ {children} +
+ ), +})); + +const currentAgent = { + agent: { + metadata: { namespace: "kagent", name: "kanban-mcp-agent" }, + spec: { type: "Declarative" }, + }, + workloadMode: "deployment", +} as unknown as AgentResponse; + +function renderLayout() { + return render( + +
chat
+
, + ); +} + +describe("ChatLayoutUI", () => { + it("mounts ChatMcpAppsProvider around the chat so MCP App widgets can render", async () => { + renderLayout(); + + const provider = await screen.findByTestId("mcp-apps-provider"); + expect(provider).toHaveAttribute("data-agent", "kanban-mcp-agent"); + // The chat content must live inside the provider, otherwise tool calls + // never resolve to MCP App widgets. + expect(provider).toContainElement(screen.getByTestId("chat-child")); + }); + + it("renders the chat children", async () => { + renderLayout(); + await waitFor(() => expect(screen.getByTestId("chat-child")).toBeInTheDocument()); + }); +}); diff --git a/ui/src/components/chat/__tests__/ChatMcpAppsContext.test.tsx b/ui/src/components/chat/__tests__/ChatMcpAppsContext.test.tsx new file mode 100644 index 0000000000..8a70ebf757 --- /dev/null +++ b/ui/src/components/chat/__tests__/ChatMcpAppsContext.test.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { + ChatMcpAppsProvider, + useChatMcpApps, +} from "@/components/chat/ChatMcpAppsContext"; +import { listMcpAppTools } from "@/app/actions/mcp-apps"; +import type { AgentResponse } from "@/types"; + +jest.mock("@/app/actions/mcp-apps", () => ({ + listMcpAppTools: jest.fn(), +})); + +const mockedListMcpAppTools = listMcpAppTools as jest.Mock; + +// An agent that selects all tools from a single RemoteMCPServer (kanban-mcp). +const currentAgent = { + agent: { + metadata: { namespace: "kagent", name: "assistant" }, + }, + tools: [ + { + type: "McpServer", + mcpServer: { + kind: "RemoteMCPServer", + apiGroup: "kagent.dev", + name: "kanban-mcp", + namespace: "kagent", + toolNames: [], // empty => selects all tools + }, + }, + ], +} as unknown as AgentResponse; + +function Probe() { + const { getMcpAppForTool, getMcpToolForAppCall } = useChatMcpApps(); + const board = getMcpAppForTool("show_board"); + const appOnly = getMcpAppForTool("internal_widget"); + const plain = getMcpAppForTool("plain_tool"); + const appOnlyTool = getMcpToolForAppCall("kagent", "kanban-mcp", "internal_widget"); + return ( +
+ {board ? `${board.serverName}:${board.uiResourceUri}` : "none"} + {appOnly ? "yes" : "none"} + {plain ? "yes" : "none"} + + {appOnlyTool ? `${appOnlyTool.toolName}:${appOnlyTool.appOnly}` : "none"} + +
+ ); +} + +describe("ChatMcpAppsProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("registers agent-visible UI tools as apps and excludes app-only and non-UI tools", async () => { + mockedListMcpAppTools.mockResolvedValue({ + error: false, + data: [ + // Visible to both model and app -> agent-visible app. + { + name: "show_board", + uiResourceUri: "ui://board", + _meta: { ui: { visibility: ["model", "app"] } }, + }, + // App-only -> not surfaced via getMcpAppForTool, but resolvable for app calls. + { + name: "internal_widget", + uiResourceUri: "ui://widget", + _meta: { ui: { visibility: "app" } }, + }, + // No UI resource -> never an app. + { name: "plain_tool" }, + ], + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("board").textContent).toBe("kanban-mcp:ui://board"); + }); + expect(screen.getByTestId("appOnly").textContent).toBe("none"); + expect(screen.getByTestId("plain").textContent).toBe("none"); + expect(screen.getByTestId("appOnlyTool").textContent).toBe("internal_widget:true"); + expect(mockedListMcpAppTools).toHaveBeenCalledWith("kagent", "kanban-mcp"); + }); + + it("registers no apps when the agent has no MCP servers", async () => { + const agentWithoutMcp = { + agent: { metadata: { namespace: "kagent", name: "assistant" } }, + tools: [], + } as unknown as AgentResponse; + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("board").textContent).toBe("none"); + }); + expect(mockedListMcpAppTools).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/mcp-apps/McpAppRenderer.tsx b/ui/src/components/mcp-apps/McpAppRenderer.tsx new file mode 100644 index 0000000000..0b8c82067c --- /dev/null +++ b/ui/src/components/mcp-apps/McpAppRenderer.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AppRenderer, type AppRendererProps } from "@mcp-ui/client"; +import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { useTheme } from "next-themes"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { useChatMcpApps } from "@/components/chat/ChatMcpAppsContext"; +import { callMcpAppTool, readMcpAppResource } from "@/app/actions/mcp-apps"; +import { installMcpAppInitCompat } from "@/lib/mcpAppInitCompat"; + +// Install at module load (before AppRenderer mounts and its transport connects) +// so non-conformant guests that send `clientInfo` instead of `appInfo` in their +// `ui/initialize` request still complete the handshake. See mcpAppInitCompat. +installMcpAppInitCompat(); + +interface McpAppRendererProps { + namespace: string; + serverName: string; + toolName: string; + toolResourceUri: string; + toolInput?: Record; + toolResult?: CallToolResult; + /** + * Delivers a message the app pushed into the conversation via the MCP Apps + * `ui/message` channel. The host injects it as a new chat turn so the agent + * can act on it (e.g. start monitoring a build after the app triggered it). + */ + onSendMessage?: (text: string) => Promise; +} + +/** Join the text content blocks of an app `ui/message` into a single prompt. */ +function extractMessageText(content: ContentBlock[] | undefined): string { + if (!Array.isArray(content)) { + return ""; + } + return content + .filter((block): block is ContentBlock & { type: "text"; text: string } => block?.type === "text" && typeof (block as { text?: unknown }).text === "string") + .map((block) => block.text) + .join("\n") + .trim(); +} + +function requireData(response: { data?: T; error?: string; message: string }): T { + if (response.error || !response.data) { + throw new Error(response.error || response.message); + } + return response.data; +} + +export function McpAppRenderer({ + namespace, + serverName, + toolName, + toolResourceUri, + toolInput, + toolResult, + onSendMessage, +}: McpAppRendererProps) { + const { resolvedTheme } = useTheme(); + const { getMcpToolForAppCall } = useChatMcpApps(); + const [sandboxUrl, setSandboxUrl] = useState(null); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const id = requestAnimationFrame(() => { + // Tell the sandbox proxy which origin to trust for postMessage, so it can + // reject messages from any other origin instead of accepting "*". + const url = new URL("/sandbox_proxy.html", window.location.origin); + url.searchParams.set("parentOrigin", window.location.origin); + setSandboxUrl(url); + }); + return () => cancelAnimationFrame(id); + }, []); + + // Only hand the app its theme/locale once it has connected. @mcp-ui/client@7.1.1 + // fires setHostContext the moment the AppBridge is created — before the iframe + // transport connects — so providing hostContext earlier throws an unhandled + // "Not connected" rejection and never reaches the guest. The first + // size-changed notification only arrives post-connect, so we use it as the + // connected signal (see handleSizeChanged). ponytail: an app that never + // reports its size won't get theme sync and falls back to its own theme — + // acceptable degradation; the upgrade path is a library fix that delivers + // hostContext at ui/initialize instead of via a pre-connect notification. + const hostContext = useMemo( + () => connected + ? { + theme: resolvedTheme === "dark" ? "dark" : "light", + locale: typeof navigator !== "undefined" ? navigator.language : "en-US", + } + : undefined, + [connected, resolvedTheme], + ); + + const sandbox = useMemo(() => sandboxUrl ? { url: sandboxUrl } : undefined, [sandboxUrl]); + + // Advertise the host channels we actually implement so capability-gated apps + // know they can proxy tool/resource calls and push messages to the chat. + const hostCapabilities = useMemo>(() => ({ + serverTools: {}, + serverResources: {}, + openLinks: {}, + message: { text: {} }, + }), []); + + const handleReadResource = useCallback>(async ({ uri }) => { + return requireData(await readMcpAppResource(namespace, serverName, uri)); + }, [namespace, serverName]); + + // An iframe-initiated tools/call is the app updating itself: proxy it to the + // MCP server and return the result to the same widget so it re-renders in + // place (MCP Apps standard). We do NOT promote it to a new chat turn, even + // when the tool is also model-visible — that would spawn a duplicate widget + // instead of updating the existing one. Apps that want the agent to act use + // the separate ui/message channel (onMessage) instead. + const handleCallTool = useCallback>(async (params) => { + const requestedToolName = typeof params.name === "string" && params.name ? params.name : toolName; + const args = typeof params.arguments === "object" && params.arguments !== null + ? (params.arguments as Record) + : {}; + const requestedTool = getMcpToolForAppCall(namespace, serverName, requestedToolName); + + if (requestedTool && !requestedTool.appOnly && !requestedTool.agentVisible) { + throw new Error(`MCP App requested tool ${requestedToolName}, but it is not configured as app-only or agent-visible.`); + } + + return requireData(await callMcpAppTool(namespace, serverName, requestedToolName, args)); + }, [getMcpToolForAppCall, namespace, serverName, toolName]); + + // ui/message: the app asks the host to inject content into the conversation. + // We forward it as a new chat turn so the agent can react (e.g. start + // monitoring a build the app just triggered). + const handleMessage = useCallback>(async (params) => { + const text = extractMessageText(params.content as ContentBlock[] | undefined); + if (!text) { + return { isError: true }; + } + if (!onSendMessage) { + return { isError: true }; + } + try { + await onSendMessage(text); + return {}; + } catch { + return { isError: true }; + } + }, [onSendMessage]); + + // Gracefully accept protocol requests we don't act on (e.g. + // ui/update-model-context) so apps that send them don't surface errors. + const handleFallbackRequest = useCallback>(async () => { + return {}; + }, []); + + const handleOpenLink = useCallback>(async ({ url }) => { + const target = new URL(String(url), window.location.href); + if (target.protocol !== "http:" && target.protocol !== "https:") { + const message = `Blocked unsupported link scheme: ${target.protocol}`; + setError(message); + throw new Error(message); + } + window.open(target.toString(), "_blank", "noopener,noreferrer"); + return {}; + }, []); + + const handleError = useCallback>((err) => { + setError(err.message); + }, []); + + // The guest can only emit size-changed once its transport is connected, so we + // treat the first one as the signal that it's safe to push hostContext. + const handleSizeChanged = useCallback>(() => { + setConnected(true); + }, []); + + if (error) { + return ( + + MCP App error + {error} + + ); + } + + if (!sandboxUrl) { + return
Preparing MCP App renderer...
; + } + + return ( +
+ } + hostContext={hostContext} + hostCapabilities={hostCapabilities} + onReadResource={handleReadResource} + onCallTool={handleCallTool} + onMessage={handleMessage} + onFallbackRequest={handleFallbackRequest} + onOpenLink={handleOpenLink} + onSizeChanged={handleSizeChanged} + onError={handleError} + /> +
+ ); +} diff --git a/ui/src/components/mcp/McpServersView.tsx b/ui/src/components/mcp/McpServersView.tsx index b640ae74db..8a2369e171 100644 --- a/ui/src/components/mcp/McpServersView.tsx +++ b/ui/src/components/mcp/McpServersView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useCallback, useEffect } from "react"; +import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import Link from "next/link"; import { Server, @@ -12,17 +12,21 @@ import { Plus, FunctionSquare, AlertCircle, + AppWindow, } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { ToolServerResponse, DiscoveredTool } from "@/types"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { deleteServer } from "@/app/actions/servers"; +import { listMcpAppTools } from "@/app/actions/mcp-apps"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { toast } from "sonner"; import { useAgents } from "@/components/AgentsProvider"; import { getDiscoveredToolDescription, getDiscoveredToolDisplayName } from "@/lib/toolUtils"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { k8sRefUtils } from "@/lib/k8sUtils"; function setsEqualString(a: Set, b: Set): boolean { if (a.size !== b.size) { @@ -58,9 +62,32 @@ export function McpServersView({ servers, isLoading, loadError, onRefresh }: Mcp const [expandedServers, setExpandedServers] = useState>(new Set()); const [showConfirmDelete, setShowConfirmDelete] = useState(null); const [openDropdownMenu, setOpenDropdownMenu] = useState(null); + // UI-capable (MCP App) tool names per server ref. DiscoveredTool carries no + // UI metadata, so we cross-reference listMcpAppTools to mark app tools inline. + const [appToolsByServer, setAppToolsByServer] = useState>>({}); + const fetchedAppServers = useRef>(new Set()); const q = searchQuery.trim().toLowerCase(); + const loadAppTools = useCallback(async (serverName: string) => { + if (fetchedAppServers.current.has(serverName) || !k8sRefUtils.isValidRef(serverName)) { + return; + } + fetchedAppServers.current.add(serverName); + const { namespace, name } = k8sRefUtils.fromRef(serverName); + const res = await listMcpAppTools(namespace, name); + // Fail-soft: servers the backend can't introspect for UI tools (e.g. an + // MCPServer CRD) just show no app markers. ponytail: no retry on error. + if (res.error || !res.data) { + return; + } + const names = new Set(res.data.filter((t) => t.uiResourceUri).map((t) => t.name)); + if (names.size === 0) { + return; + } + setAppToolsByServer((prev) => ({ ...prev, [serverName]: names })); + }, []); + const displayList = useMemo((): DisplayServer[] => { if (!q) { return servers @@ -116,6 +143,20 @@ export function McpServersView({ servers, isLoading, loadError, onRefresh }: Mcp return () => cancelAnimationFrame(id); }, [q, displayRefs]); + // Lazily fetch UI-tool classification for whichever servers are expanded + // (handles both manual toggling and search-driven auto-expand). loadAppTools + // dedupes via fetchedAppServers, so this only does real work for new servers. + // Deferred via rAF to avoid react-hooks/set-state-in-effect (same pattern as + // the search auto-expand effect above). + useEffect(() => { + const id = requestAnimationFrame(() => { + for (const serverName of expandedServers) { + void loadAppTools(serverName); + } + }); + return () => cancelAnimationFrame(id); + }, [expandedServers, loadAppTools]); + const handleDeleteServer = async (serverName: string) => { const response = await deleteServer(serverName); if (!response.error) { @@ -229,6 +270,7 @@ export function McpServersView({ servers, isLoading, loadError, onRefresh }: Mcp } const serverName: string = server.ref; const isExpanded = expandedServers.has(serverName); + const appToolNames = appToolsByServer[serverName]; return (
  • @@ -302,20 +344,32 @@ export function McpServersView({ servers, isLoading, loadError, onRefresh }: Mcp .map((tool) => { const desc = getDiscoveredToolDescription(tool); const showDesc = desc && desc !== "No description available"; + const isApp = appToolNames?.has(tool.name) ?? false; + const ToolIcon = isApp ? AppWindow : FunctionSquare; return (
    -
    -
    - {highlight(getDiscoveredToolDisplayName(tool))} +
    + + {highlight(getDiscoveredToolDisplayName(tool))} + + {isApp ? ( + + App + + ) : null}
    {showDesc ? (
    diff --git a/ui/src/lib/__tests__/mcpAppInitCompat.test.ts b/ui/src/lib/__tests__/mcpAppInitCompat.test.ts new file mode 100644 index 0000000000..e37a77bb4b --- /dev/null +++ b/ui/src/lib/__tests__/mcpAppInitCompat.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "@jest/globals"; +import { + installMcpAppInitCompat, + normalizeMcpAppInitializeMessage, +} from "@/lib/mcpAppInitCompat"; + +describe("normalizeMcpAppInitializeMessage", () => { + test("maps clientInfo to appInfo when appInfo is missing", () => { + const data = { + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { + protocolVersion: "2026-01-26", + capabilities: {}, + appCapabilities: { availableDisplayModes: ["inline"] }, + clientInfo: { name: "weather-dashboard", version: "1.0.0" }, + }, + }; + expect(normalizeMcpAppInitializeMessage(data)).toBe(true); + expect(data.params).toMatchObject({ + appInfo: { name: "weather-dashboard", version: "1.0.0" }, + }); + }); + + test("preserves an optional title from clientInfo", () => { + const data = { + method: "ui/initialize", + params: { clientInfo: { name: "w", version: "1.0.0", title: "Weather" } }, + }; + normalizeMcpAppInitializeMessage(data); + expect((data.params as { appInfo?: unknown }).appInfo).toEqual({ + name: "w", + version: "1.0.0", + title: "Weather", + }); + }); + + test("leaves conformant appInfo untouched", () => { + const data = { + method: "ui/initialize", + params: { + appInfo: { name: "conformant", version: "2.0.0" }, + clientInfo: { name: "ignored", version: "9.9.9" }, + }, + }; + expect(normalizeMcpAppInitializeMessage(data)).toBe(false); + expect(data.params.appInfo).toEqual({ name: "conformant", version: "2.0.0" }); + }); + + test("ignores non-initialize messages", () => { + expect( + normalizeMcpAppInitializeMessage({ + method: "ui/notifications/size-changed", + params: { width: 760, height: 600 }, + }), + ).toBe(false); + }); + + test("ignores initialize without a usable clientInfo", () => { + expect( + normalizeMcpAppInitializeMessage({ + method: "ui/initialize", + params: { clientInfo: { name: "missing-version" } }, + }), + ).toBe(false); + }); + + test("is null-safe for non-object input", () => { + expect(normalizeMcpAppInitializeMessage(null)).toBe(false); + expect(normalizeMcpAppInitializeMessage("ui/initialize")).toBe(false); + expect(normalizeMcpAppInitializeMessage(undefined)).toBe(false); + }); +}); + +describe("installMcpAppInitCompat (runtime mechanism)", () => { + // Mirrors production: the compat listener is installed first, then the + // ext-apps transport attaches its listener on connect. The transport must + // observe the appInfo we patched onto the same stable event.data reference. + test("fix is visible to a later-registered transport-style listener", () => { + installMcpAppInitCompat(); + + let appInfoSeenByTransport: unknown; + window.addEventListener("message", (event: MessageEvent) => { + appInfoSeenByTransport = (event.data as { params?: { appInfo?: unknown } })?.params?.appInfo; + }); + + window.dispatchEvent( + new MessageEvent("message", { + data: { + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { clientInfo: { name: "weather-dashboard", version: "1.0.0" } }, + }, + }), + ); + + expect(appInfoSeenByTransport).toEqual({ + name: "weather-dashboard", + version: "1.0.0", + }); + }); +}); diff --git a/ui/src/lib/__tests__/mcpAppToolResult.test.ts b/ui/src/lib/__tests__/mcpAppToolResult.test.ts new file mode 100644 index 0000000000..980d1f34f4 --- /dev/null +++ b/ui/src/lib/__tests__/mcpAppToolResult.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "@jest/globals"; +import { + buildMcpAppRenderPayload, + normalizeMcpToolArgs, + toMcpAppToolResult, + withMcpAppToolInput, +} from "@/lib/mcpAppToolResult"; + +describe("mcpAppToolResult", () => { + test("normalizeMcpToolArgs unwraps nested arguments", () => { + expect(normalizeMcpToolArgs({ arguments: { location: "New York" } })).toEqual({ + location: "New York", + }); + }); + + test("normalizeMcpToolArgs parses JSON strings", () => { + expect(normalizeMcpToolArgs('{"location":"Chicago"}')).toEqual({ location: "Chicago" }); + }); + + test("toMcpAppToolResult maps ADK output wrapper to structuredContent", () => { + const result = toMcpAppToolResult( + { output: { temperature: 33, conditions: "Cloudy", humidity: 82 } }, + "", + ); + expect(result?.structuredContent).toEqual({ + temperature: 33, + conditions: "Cloudy", + humidity: 82, + }); + }); + + test("toMcpAppToolResult preserves MCP structuredContent when content is compacted", () => { + const result = toMcpAppToolResult( + { + content: [{ type: "text", text: "rendered" }], + structuredContent: { temperature: 36, conditions: "Rain", humidity: 82 }, + }, + "", + ); + expect(result?.structuredContent).toEqual({ + temperature: 36, + conditions: "Rain", + humidity: 82, + }); + }); + + test("withMcpAppToolInput merges location into structuredContent", () => { + const merged = withMcpAppToolInput( + { + content: [{ type: "text", text: "ok" }], + structuredContent: { temperature: 36, conditions: "Rain", humidity: 82 }, + }, + { location: "Chicago" }, + ); + expect(merged?.structuredContent).toEqual({ + location: "Chicago", + temperature: 36, + conditions: "Rain", + humidity: 82, + }); + }); + + test("buildMcpAppRenderPayload passes location to toolInput and structuredContent", () => { + const { toolInput, toolResult } = buildMcpAppRenderPayload( + { output: { temperature: 33, conditions: "Cloudy", humidity: 82 } }, + "", + { location: "New York" }, + ); + expect(toolInput).toEqual({ location: "New York" }); + expect(toolResult?.structuredContent).toEqual({ + location: "New York", + temperature: 33, + conditions: "Cloudy", + humidity: 82, + }); + }); +}); diff --git a/ui/src/lib/mcpAppInitCompat.ts b/ui/src/lib/mcpAppInitCompat.ts new file mode 100644 index 0000000000..b8b32f634f --- /dev/null +++ b/ui/src/lib/mcpAppInitCompat.ts @@ -0,0 +1,99 @@ +/** + * Compatibility shim for MCP Apps guest widgets that still send the base-MCP + * `clientInfo` field in their `ui/initialize` request instead of the MCP Apps + * `appInfo` field required by `@modelcontextprotocol/ext-apps`. + * + * Without this, the host's AppBridge rejects the handshake with + * `-32603 invalid_type at params.appInfo`, never emits the `initialized` + * acknowledgement, and therefore never sends `tool-input`/`tool-result` — so + * such widgets hang forever on their loading state (e.g. "Waiting for data…"). + * + * The guest posts `ui/initialize` straight to the host window (the sandbox + * proxy `document.write`s the guest HTML into its own document, replacing its + * relay listener), so the only host-side seam is the window `message` intake. + * We register early — before `@mcp-ui/client` creates its transport — and + * mutate the message in place so the ext-apps transport validates the fixed + * payload. `MessageEvent.data` returns a stable object reference, so the + * in-place edit is visible to listeners that run after us. + * + * ponytail: this only patches the one known-divergent field (`clientInfo` → + * `appInfo`). The correct fix is conformant guest widgets; the upgrade path is + * deleting this shim once widgets send `appInfo` themselves. + */ + +interface AppIdentity { + name: string; + version: string; + title?: string; +} + +interface UiInitializeRequest { + method: "ui/initialize"; + params: Record & { + appInfo?: unknown; + clientInfo?: unknown; + }; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUiInitializeRequest(value: unknown): value is UiInitializeRequest { + return ( + isPlainObject(value) && + value.method === "ui/initialize" && + isPlainObject(value.params) + ); +} + +function isAppIdentity(value: unknown): value is AppIdentity { + return ( + isPlainObject(value) && + typeof value.name === "string" && + typeof value.version === "string" + ); +} + +/** + * If `data` is a `ui/initialize` request that carries `clientInfo` but no + * `appInfo`, derive `appInfo` from it in place. Returns true when a fix was + * applied. Exported for unit testing. + */ +export function normalizeMcpAppInitializeMessage(data: unknown): boolean { + if (!isUiInitializeRequest(data)) { + return false; + } + const { params } = data; + if (params.appInfo !== undefined) { + return false; + } + if (!isAppIdentity(params.clientInfo)) { + return false; + } + const { name, version, title } = params.clientInfo; + params.appInfo = title ? { name, version, title } : { name, version }; + return true; +} + +let installed = false; + +/** + * Idempotently install the host-side `ui/initialize` normalizer. Must run + * before `@mcp-ui/client` attaches its transport listener so we win the + * registration-order race at the message target; importing this from the + * MCP App renderer module guarantees that. + */ +export function installMcpAppInitCompat(): void { + if (installed || typeof window === "undefined") { + return; + } + installed = true; + window.addEventListener( + "message", + (event: MessageEvent) => { + normalizeMcpAppInitializeMessage(event.data); + }, + true, + ); +} diff --git a/ui/src/lib/mcpAppToolResult.ts b/ui/src/lib/mcpAppToolResult.ts new file mode 100644 index 0000000000..e0649f23df --- /dev/null +++ b/ui/src/lib/mcpAppToolResult.ts @@ -0,0 +1,121 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +function isCallToolResult(value: unknown): value is CallToolResult { + return typeof value === "object" && value !== null && Array.isArray((value as { content?: unknown }).content); +} + +function stringifyToolPayload(value: unknown, fallback: string): string { + if (typeof value === "string") { + return value; + } + if (value === undefined || value === null) { + return fallback; + } + try { + return JSON.stringify(value); + } catch { + return fallback; + } +} + +/** Normalize tool-call args from chat history into a plain object for MCP Apps toolInput. */ +export function normalizeMcpToolArgs(args: unknown): Record { + let value = args; + if (typeof value === "string") { + try { + value = JSON.parse(value) as unknown; + } catch { + return {}; + } + } + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + const record = value as Record; + const nested = record.arguments; + if (typeof nested === "object" && nested !== null && !Array.isArray(nested)) { + return nested as Record; + } + return record; +} + +function unwrapAgentToolPayload(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + const record = value as Record; + if ("result" in record && typeof record.result === "object" && record.result !== null) { + return unwrapAgentToolPayload(record.result) ?? (record.result as Record); + } + return record; +} + +function extractStructuredPayload(record: Record): Record | undefined { + if (record.structuredContent != null && typeof record.structuredContent === "object" && !Array.isArray(record.structuredContent)) { + return record.structuredContent as Record; + } + if (record.output != null && typeof record.output === "object" && !Array.isArray(record.output)) { + return record.output as Record; + } + return undefined; +} + +/** + * Convert agent/MCP tool responses into a CallToolResult for @mcp-ui/client. + * Handles ADK mcptoolset `{ output: structuredContent }` and full MCP results. + */ +export function toMcpAppToolResult(value: unknown, fallbackContent: string): CallToolResult | undefined { + const record = unwrapAgentToolPayload(value); + if (!record) { + return undefined; + } + + const structuredContent = extractStructuredPayload(record); + const isError = record.isError === true || record.error === true; + + if (structuredContent) { + return { + content: isCallToolResult(record) + ? record.content + : [{ type: "text", text: stringifyToolPayload(structuredContent, fallbackContent) }], + structuredContent, + isError, + }; + } + + if (isCallToolResult(record)) { + return record; + } + + return undefined; +} + +/** Merge tool-call args (e.g. location) into structuredContent for MCP App widgets. */ +export function withMcpAppToolInput( + toolResult: CallToolResult | undefined, + toolArgs: Record, +): CallToolResult | undefined { + if (!toolResult || Object.keys(toolArgs).length === 0) { + return toolResult; + } + return { + ...toolResult, + structuredContent: { + ...toolArgs, + ...(toolResult.structuredContent ?? {}), + }, + }; +} + +export function buildMcpAppRenderPayload( + rawResult: unknown, + fallbackContent: string, + toolArgs: unknown, +): { toolInput: Record; toolResult: CallToolResult | undefined } { + const toolInput = normalizeMcpToolArgs(toolArgs); + const toolResult = withMcpAppToolInput( + toMcpAppToolResult(rawResult, fallbackContent), + toolInput, + ); + return { toolInput, toolResult }; +} diff --git a/ui/src/lib/messageHandlers.ts b/ui/src/lib/messageHandlers.ts index df0be16595..d3a277f3d3 100644 --- a/ui/src/lib/messageHandlers.ts +++ b/ui/src/lib/messageHandlers.ts @@ -122,6 +122,7 @@ export function extractMessagesFromTasks(tasks: Task[]): Message[] { name: toolData.name, content: normalizeToolResultToText(toolData), is_error: toolData.response?.isError || false, + raw_result: getRawToolResult(toolData), ...(frSubagentSessionId ? { subagent_session_id: frSubagentSessionId } : {}), }], }, @@ -555,6 +556,7 @@ export interface ToolResponseData { response?: { isError?: boolean; result?: unknown; + [key: string]: unknown; }; } @@ -571,9 +573,14 @@ export interface ProcessedToolResultData { name: string; content: string; is_error: boolean; + raw_result?: unknown; subagent_session_id?: string; } +export function getRawToolResult(toolData: ToolResponseData): unknown { + return toolData.response?.result ?? toolData.response; +} + // Normalize various tool response result shapes into plain text export function normalizeToolResultToText(toolData: ToolResponseData): string { const result = toolData.response?.result || toolData.response; @@ -805,6 +812,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { name: toolData.name, content, is_error: toolData.response?.isError || false, + raw_result: getRawToolResult(toolData), ...(subagentSessionId ? { subagent_session_id: subagentSessionId } : {}), }]; const execEvent = createMessage( @@ -1070,6 +1078,7 @@ export const createMessageHandlers = (handlers: MessageHandlers) => { name: toolData.name, content: textContent, is_error: toolData.response?.isError || false, + raw_result: getRawToolResult(toolData), ...(artifactSubagentSessionId ? { subagent_session_id: artifactSubagentSessionId } : {}), }]; const convertedMessage = createMessage("", source, { originalType: "ToolCallExecutionEvent", contextId: artifactUpdate.contextId, taskId: artifactUpdate.taskId, additionalMetadata: { toolResultData: toolResultContent } });