diff --git a/.gitignore b/.gitignore index d6cc398fc0..d4a9ea413b 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..b95f180c30 --- /dev/null +++ b/design/EP-2046-chat-mcp-ui-widgets.md @@ -0,0 +1,123 @@ +# 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 + (surfaced at `app/apps/[appName]/page.tsx`, reachable by clicking an MCP app + listed alongside a server's tools 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..bf0f0dbfb4 --- /dev/null +++ b/go/adk/pkg/agent/mcp_apps.go @@ -0,0 +1,102 @@ +package agent + +import ( + "encoding/json" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/adk/agent" + "google.golang.org/adk/agent/llmagent" + adkmodel "google.golang.org/adk/model" +) + +// mcpAppRenderedNotice is the terminal message the model sees in place of an +// MCP App tool's render payload. An MCP App tool (one that declares a UI +// resourceUri) produces an interactive view that is displayed to the user and +// refreshes itself in-place via its own app-only tool calls. The model must +// treat a successful render as a completed, self-updating artifact; otherwise it +// tends to re-invoke the rendering tool on every "refresh", flooding the chat +// with duplicate app cards. This notice is protocol-oriented: it applies to any +// tool carrying a UI resourceUri, independent of the tool's name or payload keys. +const mcpAppRenderedNotice = "The interactive UI for this tool has been rendered to the user and now updates live inside the app. Treat this as complete and do not call this tool again unless the user explicitly asks for it." + +// MakeMCPAppModelResultCallback replaces what the model sees for MCP App +// (UI-rendering) tool results: instead of the heavy render payload it receives a +// terminal directive (see mcpAppRenderedNotice). The full result is still +// streamed to the UI separately, so this only changes the model's view and +// prevents the model from looping on the rendering tool. Errors are passed +// through so the model can still react to and recover from failures. +func MakeMCPAppModelResultCallback(appToolNames map[string]bool) llmagent.BeforeModelCallback { + return func(_ agent.CallbackContext, req *adkmodel.LLMRequest) (*adkmodel.LLMResponse, error) { + for _, content := range req.Contents { + if content == nil { + continue + } + for _, part := range content.Parts { + if part == nil || part.FunctionResponse == nil || !appToolNames[part.FunctionResponse.Name] { + continue + } + part.FunctionResponse.Response = compactMCPAppModelResponse(part.FunctionResponse.Response) + } + } + return nil, nil + } +} + +// compactMCPAppModelResponse rewrites an MCP App tool result for the model. +// +// The model exchanges tool results as a generic map (genai +// FunctionResponse.Response), but the payload is really an MCP +// [mcpsdk.CallToolResult]. We decode it into that typed result so the logic +// works against real fields (IsError, Content, Meta, StructuredContent) rather +// than poking at string keys. If the payload isn't a recognizable MCP result we +// leave it untouched. +func compactMCPAppModelResponse(response map[string]any) map[string]any { + result, err := decodeCallToolResult(response) + if err != nil { + return response + } + + if result.IsError { + // On error, keep the original content/meta so the model can + // diagnose and recover; only drop the heavy structured payload. + result.StructuredContent = nil + return encodeCallToolResult(result, response) + } + + // On success, collapse the render payload into a terminal directive so the + // model stops re-invoking the rendering tool. Preserve _meta (e.g. + // resourceUri) in case downstream tooling relies on it. + compact := &mcpsdk.CallToolResult{ + Meta: result.Meta, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: mcpAppRenderedNotice}}, + } + return encodeCallToolResult(compact, response) +} + +// decodeCallToolResult interprets a generic model-facing response map as a typed +// MCP CallToolResult. +func decodeCallToolResult(response map[string]any) (*mcpsdk.CallToolResult, error) { + raw, err := json.Marshal(response) + if err != nil { + return nil, err + } + var result mcpsdk.CallToolResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} + +// encodeCallToolResult converts a typed CallToolResult back into the generic map +// the model expects, falling back to the original response if conversion fails. +func encodeCallToolResult(result *mcpsdk.CallToolResult, fallback map[string]any) map[string]any { + raw, err := json.Marshal(result) + if err != nil { + return fallback + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return fallback + } + return out +} 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..d353e86ee2 --- /dev/null +++ b/go/adk/pkg/agent/mcp_apps_test.go @@ -0,0 +1,145 @@ +package agent + +import ( + "testing" + + adkmodel "google.golang.org/adk/model" + "google.golang.org/genai" +) + +func TestMakeMCPAppModelResultCallbackReplacesRenderPayloadWithNotice(t *testing.T) { + t.Parallel() + + req := &adkmodel.LLMRequest{ + Contents: []*genai.Content{{ + Parts: []*genai.Part{{ + FunctionResponse: &genai.FunctionResponse{ + Name: "jenkins_monitor_build", + Response: map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": "Opened Jenkins Build Monitor for https://example.com/job/demo/1/ (current status: IN_PROGRESS).", + }}, + "structuredContent": map[string]any{ + "build": map[string]any{ + "stages": []any{map[string]any{"name": "Deploy", "status": "IN_PROGRESS"}}, + }, + "polling_data": "large payload", + }, + "_meta": map[string]any{ + "ui": map[string]any{ + "resourceUri": "ui://jenkins-mcp/build-monitor", + }, + }, + }, + }, + }}, + }}, + } + + callback := MakeMCPAppModelResultCallback(map[string]bool{"jenkins_monitor_build": true}) + if _, err := callback(nil, req); err != nil { + t.Fatalf("callback returned error: %v", err) + } + + got := req.Contents[0].Parts[0].FunctionResponse.Response + + // Success render payload should be collapsed into the terminal notice so the + // model stops re-invoking the rendering tool. + content, ok := got["content"].([]any) + if !ok || len(content) != 1 { + t.Fatalf("content not replaced with notice: %#v", got["content"]) + } + part, ok := content[0].(map[string]any) + if !ok || part["text"] != mcpAppRenderedNotice { + t.Fatalf("notice text missing: %#v", content[0]) + } + + // Should strip structuredContent (heavy render payload). + if _, ok := got["structuredContent"]; ok { + t.Fatalf("structuredContent should be stripped, got: %#v", got) + } + + // Should preserve _meta + meta, ok := got["_meta"].(map[string]any) + if !ok { + t.Fatalf("_meta not preserved: %#v", got["_meta"]) + } + if _, ok := meta["ui"]; !ok { + t.Fatalf("_meta.ui not preserved: %#v", meta) + } +} + +func TestMakeMCPAppModelResultCallbackPreservesIsError(t *testing.T) { + t.Parallel() + + req := &adkmodel.LLMRequest{ + Contents: []*genai.Content{{ + Parts: []*genai.Part{{ + FunctionResponse: &genai.FunctionResponse{ + Name: "jenkins_monitor_build", + Response: map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": "Tool execution failed.", + }}, + "structuredContent": map[string]any{"error": "connection timeout"}, + "isError": true, + }, + }, + }}, + }}, + } + + callback := MakeMCPAppModelResultCallback(map[string]bool{"jenkins_monitor_build": true}) + if _, err := callback(nil, req); err != nil { + t.Fatalf("callback returned error: %v", err) + } + + got := req.Contents[0].Parts[0].FunctionResponse.Response + + // Should preserve isError + isErr, ok := got["isError"].(bool) + if !ok || !isErr { + t.Fatalf("isError not preserved or false: %#v", got["isError"]) + } + + // Should still strip structuredContent + if _, ok := got["structuredContent"]; ok { + t.Fatalf("structuredContent should be stripped") + } +} + +func TestMakeMCPAppModelResultCallbackLeavesNonAppToolsAlone(t *testing.T) { + t.Parallel() + + original := map[string]any{ + "output": map[string]any{"answer": 42}, + "content": []map[string]any{{ + "type": "text", + "text": "Answer is 42", + }}, + } + req := &adkmodel.LLMRequest{ + Contents: []*genai.Content{{ + Parts: []*genai.Part{{ + FunctionResponse: &genai.FunctionResponse{ + Name: "regular_tool", + Response: original, + }, + }}, + }}, + } + + callback := MakeMCPAppModelResultCallback(map[string]bool{"some_app_tool": true}) + if _, err := callback(nil, req); err != nil { + t.Fatalf("callback returned error: %v", err) + } + + got := req.Contents[0].Parts[0].FunctionResponse.Response + + // Non-app tools should pass through unchanged + if _, ok := got["output"]; !ok { + t.Fatalf("non-app tool response modified: %#v", got) + } +} diff --git a/go/adk/pkg/mcp/mcp_ui.go b/go/adk/pkg/mcp/mcp_ui.go new file mode 100644 index 0000000000..f757544866 --- /dev/null +++ b/go/adk/pkg/mcp/mcp_ui.go @@ -0,0 +1,242 @@ +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" +) + +const ( + // mcpUIExtensionName is the MCP Apps extension identifier negotiated via + // capabilities.extensions during initialize. Advertising it lets conformant + // servers that only register UI tools when the client supports them expose + // those tools to kagent. + mcpUIExtensionName = "io.modelcontextprotocol/ui" + // mcpAppHTMLMimeType is the only UI content type defined by the MCP Apps MVP. + mcpAppHTMLMimeType = "text/html;profile=mcp-app" +) + +// mcpUIClientCapabilities advertises MCP Apps support so capability-gated +// servers expose their UI tools. +func mcpUIClientCapabilities() *mcpsdk.ClientCapabilities { + caps := &mcpsdk.ClientCapabilities{} + caps.AddExtension(mcpUIExtensionName, map[string]any{"mimeTypes": []string{mcpAppHTMLMimeType}}) + return caps +} + +// 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 is this server's set of model-visible tool names whose + // results render as interactive MCP App (UI) widgets (the tool declares a + // `_meta.ui.resourceUri`). Used as a set, so only key presence is meaningful. + appToolNames map[string]bool +} + +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. The agent attaches the +// model-result compaction callback (see agent.MakeMCPAppModelResultCallback) +// only to these tools. +func MCPAppToolNamesFromToolsets(toolsets []tool.Toolset) map[string]bool { + out := make(map[string]bool) + 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 + // mcpToolKindAppInternal 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". + mcpToolKindAppInternal +) + +func (k mcpToolKind) String() string { + switch k { + case mcpToolKindApp: + return "app" + case mcpToolKindAppInternal: + return "app_internal" + 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-internal 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 mcpToolKindAppInternal + } + 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, map[string]bool, 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"}, &mcpsdk.ClientOptions{Capabilities: mcpUIClientCapabilities()}) + 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(map[string]bool) + for _, t := range result.Tools { + if t == nil || t.Name == "" { + continue + } + if len(configuredFilter) > 0 && !configuredFilter[t.Name] { + continue + } + switch mcpToolKindOf(t.Meta) { + case mcpToolKindAppInternal: + // 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..4fc804fff3 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: map[string]bool{"show_board": true}}, + &mcpAppToolset{inner: inner, appToolNames: map[string]bool{"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: mcpToolKindAppInternal, + }, + { + name: "app visibility string is app-only", + meta: mcpsdk.Meta{"ui": map[string]any{"visibility": "app"}}, + want: mcpToolKindAppInternal, + }, + { + 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: mcpToolKindAppInternal, + }, + { + 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 c9571b9214..d04f518f23 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 29623c17a6..82a1e288a0 100644 --- a/go/core/internal/httpserver/handlers/handlers.go +++ b/go/core/internal/httpserver/handlers/handlers.go @@ -28,6 +28,7 @@ type Handlers struct { Agents *AgentsHandler Tools *ToolsHandler ToolServers *ToolServersHandler + MCPApps *MCPAppsHandler ToolServerTypes *ToolServerTypesHandler Memory *MemoryHandler Feedback *FeedbackHandler @@ -91,6 +92,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..a136917307 --- /dev/null +++ b/go/core/internal/httpserver/handlers/mcpapps.go @@ -0,0 +1,469 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "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" + +// mcpUIExtensionName is the MCP Apps extension identifier negotiated via +// capabilities.extensions during initialize. Advertising it lets conformant +// servers that gate UI tools on client support expose them to kagent. +const mcpUIExtensionName = "io.modelcontextprotocol/ui" + +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, groupKind, ok := h.mcpServerRef(w, r) + if !ok { + return + } + + session, cancel, err := h.connect(r.Context(), namespace, name, groupKind) + 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, groupKind, ok := h.mcpServerRef(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, groupKind) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to connect to MCP server", err)) + return + } + defer cancel() + defer session.Close() + + // This endpoint only serves app-originated tools/call requests. Per the MCP + // Apps spec the host MUST reject app calls to tools whose visibility does not + // include "app" (e.g. model-only tools), so enforce it server-side rather + // than trusting the client. + allowed, found, err := toolAllowsAppCall(r.Context(), session, toolName) + if err != nil { + w.RespondWithError(errors.NewInternalServerError("Failed to verify MCP tool visibility", err)) + return + } + if !found { + w.RespondWithError(errors.NewNotFoundError(fmt.Sprintf("MCP tool %q not found", toolName), nil)) + return + } + if !allowed { + w.RespondWithError(errors.NewForbiddenError(fmt.Sprintf("MCP tool %q is not callable by apps (visibility does not include \"app\")", toolName), nil)) + return + } + + 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, groupKind, ok := h.mcpServerRef(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, groupKind) + 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) mcpServerRef(w ErrorResponseWriter, r *http.Request) (namespace, name, groupKind string, ok 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 + } + // groupKind (e.g. "RemoteMCPServer.kagent.dev" or "MCPServer.kagent.dev") + // disambiguates the two tool-server CRDs when they share a namespace/name. + // Optional for backward compatibility; the UI sends the kind of the server + // the user selected. + return namespace, name, r.URL.Query().Get("groupKind"), true +} + +// mcpServerCRDKind extracts the CRD kind from a groupKind string, ignoring the +// API group so both "MCPServer" and "MCPServer.kagent.dev" resolve to the same +// kind. +func mcpServerCRDKind(groupKind string) string { + kind, _, _ := strings.Cut(groupKind, ".") + return kind +} + +// resolveMCPServerEndpoint resolves the tool server named by the ref into the +// RemoteMCPServer shape the controller uses for tool discovery, so both CRD +// kinds share one connect path. An MCPServer is converted to that shape via +// ConvertMCPServerToRemoteMCPServer. +// +// groupKind selects which CRD to read so a RemoteMCPServer and an MCPServer +// that share a namespace/name resolve to the one the caller actually selected: +// +// - "RemoteMCPServer": read the RemoteMCPServer only. +// - "MCPServer": read the kmcp MCPServer only. +// - empty/unknown: fall back to trying RemoteMCPServer first, then MCPServer +// (legacy behavior for callers that don't pass a kind). +func (h *MCPAppsHandler) resolveMCPServerEndpoint(ctx context.Context, namespace, name, groupKind string) (*v1alpha2.RemoteMCPServer, error) { + key := client.ObjectKey{Namespace: namespace, Name: name} + + switch mcpServerCRDKind(groupKind) { + case "MCPServer": + server, found, err := h.getMCPServerEndpoint(ctx, key) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("no MCPServer %s/%s found", namespace, name) + } + return server, nil + case "RemoteMCPServer": + server, found, err := h.getRemoteMCPServer(ctx, key) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("no RemoteMCPServer %s/%s found", namespace, name) + } + return server, nil + default: + if server, found, err := h.getRemoteMCPServer(ctx, key); err != nil { + return nil, err + } else if found { + return server, nil + } + server, found, err := h.getMCPServerEndpoint(ctx, key) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("no RemoteMCPServer or MCPServer %s/%s found", namespace, name) + } + return server, nil + } +} + +// getRemoteMCPServer reads a RemoteMCPServer. found is false (with nil error) +// when no such CRD exists, so callers can decide whether to try another kind. +func (h *MCPAppsHandler) getRemoteMCPServer(ctx context.Context, key client.ObjectKey) (*v1alpha2.RemoteMCPServer, bool, error) { + server := &v1alpha2.RemoteMCPServer{} + if err := h.KubeClient.Get(ctx, key, server); err != nil { + if apierrors.IsNotFound(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("failed to get RemoteMCPServer %s/%s: %w", key.Namespace, key.Name, err) + } + return server, true, nil +} + +// getMCPServerEndpoint reads a kmcp MCPServer (an in-cluster Deployment+Service) +// and converts it to the RemoteMCPServer shape. found is false (with nil error) +// when no such CRD exists. +func (h *MCPAppsHandler) getMCPServerEndpoint(ctx context.Context, key client.ObjectKey) (*v1alpha2.RemoteMCPServer, bool, error) { + mcpServer := &kmcp.MCPServer{} + if err := h.KubeClient.Get(ctx, key, mcpServer); err != nil { + if apierrors.IsNotFound(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("failed to get MCPServer %s/%s: %w", key.Namespace, key.Name, err) + } + server, err := agent_translator.ConvertMCPServerToRemoteMCPServer(mcpServer) + if err != nil { + return nil, true, fmt.Errorf("failed to resolve MCPServer %s/%s endpoint: %w", key.Namespace, key.Name, err) + } + return server, true, nil +} + +func (h *MCPAppsHandler) connect(ctx context.Context, namespace, name, groupKind string) (*mcp.ClientSession, context.CancelFunc, error) { + log := ctrllog.FromContext(ctx).WithName("mcp-apps-handler").WithValues("namespace", namespace, "name", name, "groupKind", groupKind) + + server, err := h.resolveMCPServerEndpoint(ctx, namespace, name, groupKind) + 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, + } + caps := &mcp.ClientCapabilities{} + caps.AddExtension(mcpUIExtensionName, map[string]any{"mimeTypes": []string{mcpAppHTMLMimeType}}) + client := mcp.NewClient(impl, &mcp.ClientOptions{Capabilities: caps}) + 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 +} + +// extractUIVisibility reads `_meta.ui.visibility`, which the MCP Apps spec +// allows as either a single string or a list of strings. +func extractUIVisibility(meta map[string]any) []string { + ui, ok := meta["ui"].(map[string]any) + if !ok { + return nil + } + switch v := ui["visibility"].(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 +} + +// visibilityAllowsApp reports whether an app may call a tool. Per the MCP Apps +// spec visibility defaults to ["model","app"], so absent/empty visibility is +// app-callable; otherwise "app" must be present. +func visibilityAllowsApp(meta map[string]any) bool { + visibility := extractUIVisibility(meta) + if len(visibility) == 0 { + return true + } + return slices.Contains(visibility, "app") +} + +// toolAllowsAppCall lists the server's tools (following pagination), finds the +// named tool, and reports whether it is app-callable. found is false when no +// tool with that name exists. +func toolAllowsAppCall(ctx context.Context, session *mcp.ClientSession, toolName string) (allowed bool, found bool, err error) { + params := &mcp.ListToolsParams{} + for { + result, err := session.ListTools(ctx, params) + if err != nil { + return false, false, err + } + for _, tool := range result.Tools { + if tool != nil && tool.Name == toolName { + return visibilityAllowsApp(tool.Meta), true, nil + } + } + if result.NextCursor == "" { + return false, false, nil + } + params.Cursor = result.NextCursor + } +} + +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..e501bee88e --- /dev/null +++ b/go/core/internal/httpserver/handlers/mcpapps_test.go @@ -0,0 +1,183 @@ +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" +) + +// TestVisibilityAllowsApp pins the spec rule the call-tool handler enforces: +// visibility defaults to ["model","app"], so only a tool that explicitly omits +// "app" is rejected for app-originated calls. +func TestVisibilityAllowsApp(t *testing.T) { + tests := []struct { + name string + meta map[string]any + want bool + }{ + {name: "no meta defaults to app-callable", meta: nil, want: true}, + {name: "empty ui defaults to app-callable", meta: map[string]any{"ui": map[string]any{}}, want: true}, + {name: "model and app list", meta: map[string]any{"ui": map[string]any{"visibility": []any{"model", "app"}}}, want: true}, + {name: "app-only string", meta: map[string]any{"ui": map[string]any{"visibility": "app"}}, want: true}, + {name: "app-only list", meta: map[string]any{"ui": map[string]any{"visibility": []any{"app"}}}, want: true}, + {name: "model-only is rejected", meta: map[string]any{"ui": map[string]any{"visibility": []any{"model"}}}, want: false}, + {name: "model-only string is rejected", meta: map[string]any{"ui": map[string]any{"visibility": "model"}}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := visibilityAllowsApp(tt.meta); got != tt.want { + t.Errorf("visibilityAllowsApp(%v) = %v, want %v", tt.meta, got, tt.want) + } + }) + } +} + +// TestResolveMCPServerEndpoint pins the dual-CRD resolution: groupKind selects +// which CRD to read (so a RemoteMCPServer and MCPServer sharing a namespace/name +// resolve deterministically to the one the caller selected), a kmcp MCPServer is +// converted to the in-cluster Service URL, and a missing ref returns a clear +// error. An empty groupKind keeps the legacy RemoteMCPServer-first fallback. +func TestResolveMCPServerEndpoint(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 + + // Same namespace/name registered as both CRD kinds, to prove groupKind + // disambiguates them. + collideRemote := &v1alpha2.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "shared", Namespace: "clash"}, + Spec: v1alpha2.RemoteMCPServerSpec{ + URL: "https://remote.example.com/mcp", + Protocol: v1alpha2.RemoteMCPServerProtocolStreamableHttp, + }, + } + collideMCP := &kmcp.MCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "shared", Namespace: "clash"}, + } + collideMCP.Spec.Deployment.Port = 9090 + + tests := []struct { + name string + objects []client.Object + namespace string + server string + groupKind string + wantURL string + wantErr string + }{ + { + name: "RemoteMCPServer used directly", + objects: []client.Object{remote}, + namespace: "default", + server: "remote", + groupKind: "RemoteMCPServer.kagent.dev", + wantURL: "https://example.com/mcp", + }, + { + name: "kmcp MCPServer converted to service URL", + objects: []client.Object{mcpServer}, + namespace: "team", + server: "local", + groupKind: "MCPServer.kagent.dev", + wantURL: "http://local.team:8080/mcp", + }, + { + name: "empty groupKind falls back to RemoteMCPServer first", + objects: []client.Object{remote}, + namespace: "default", + server: "remote", + wantURL: "https://example.com/mcp", + }, + { + name: "empty groupKind falls back to MCPServer when no RemoteMCPServer", + objects: []client.Object{mcpServer}, + namespace: "team", + server: "local", + wantURL: "http://local.team:8080/mcp", + }, + { + name: "collision resolves to MCPServer when kind is MCPServer", + objects: []client.Object{collideRemote, collideMCP}, + namespace: "clash", + server: "shared", + groupKind: "MCPServer.kagent.dev", + wantURL: "http://shared.clash:9090/mcp", + }, + { + name: "collision resolves to RemoteMCPServer when kind is RemoteMCPServer", + objects: []client.Object{collideRemote, collideMCP}, + namespace: "clash", + server: "shared", + groupKind: "RemoteMCPServer.kagent.dev", + wantURL: "https://remote.example.com/mcp", + }, + { + name: "kind without group suffix still resolves", + objects: []client.Object{collideRemote, collideMCP}, + namespace: "clash", + server: "shared", + groupKind: "MCPServer", + wantURL: "http://shared.clash:9090/mcp", + }, + { + name: "explicit RemoteMCPServer kind but only MCPServer exists", + objects: []client.Object{mcpServer}, + namespace: "team", + server: "local", + groupKind: "RemoteMCPServer.kagent.dev", + wantErr: "no RemoteMCPServer team/local found", + }, + { + 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.resolveMCPServerEndpoint(context.Background(), tt.namespace, tt.server, tt.groupKind) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("resolveMCPServerEndpoint() error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolveMCPServerEndpoint() unexpected error: %v", err) + } + if got.Spec.URL != tt.wantURL { + t.Errorf("resolveMCPServerEndpoint() 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 0635d52dea..ed3a265dcf 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" @@ -264,6 +265,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..d3e7b9b651 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,22 @@ 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 visibility/mcp_app_resource_uri properties would raise. + visibility = getattr(tool, "visibility", None) or [] + # App-only tools (_meta.ui.visibility declares "app" but not + # "model") MUST NOT be exposed to the model; they stay callable + # only from the rendered MCP App. Mirrors the Go ADK filter in + # go/adk/pkg/mcp/mcp_ui.go (mcpToolKindAppInternal). + if "app" in visibility and "model" not in visibility: + continue + 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 bfa94fb92b..867ac317d5 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -14,6 +14,7 @@ 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._remote_a2a_tool import KAgentRemoteA2AToolset from kagent.adk.models._anthropic import KAgentAnthropicLlm @@ -395,6 +396,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 @@ -414,6 +418,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: @@ -431,6 +436,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: @@ -518,7 +524,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( @@ -529,7 +541,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/python/packages/kagent-adk/tests/unittests/test_mcp_connection_error_handling.py b/python/packages/kagent-adk/tests/unittests/test_mcp_connection_error_handling.py index a062f2eafe..d1adbfadbe 100644 --- a/python/packages/kagent-adk/tests/unittests/test_mcp_connection_error_handling.py +++ b/python/packages/kagent-adk/tests/unittests/test_mcp_connection_error_handling.py @@ -14,9 +14,24 @@ from mcp.shared.exceptions import McpError from mcp.types import ErrorData +from kagent.adk._mcp_apps import MCPAppToolNames from kagent.adk._mcp_toolset import ConnectionSafeMcpTool, KAgentMcpToolset +def _make_mcp_tool(name, visibility=None, resource_uri=None): + """Build a real McpTool stub whose visibility/mcp_app_resource_uri + properties read from a fake raw MCP tool's _meta.ui block.""" + tool = McpTool.__new__(McpTool) + tool.name = name + ui = {} + if visibility is not None: + ui["visibility"] = visibility + if resource_uri is not None: + ui["resourceUri"] = resource_uri + tool._mcp_tool = MagicMock(meta={"ui": ui} if ui else None) + return tool + + def _make_connection_safe_tool(side_effect): """Create a ConnectionSafeMcpTool wrapping a mock McpTool.""" inner_tool = MagicMock(spec=McpTool) @@ -155,3 +170,30 @@ async def mock_super_get_tools(self_arg, readonly_context=None): assert tools[0].name == "wrapped-tool" assert tools[0]._some_attr == "value" assert tools[1] is fake_other_tool + + +@pytest.mark.asyncio +async def test_get_tools_hides_app_only_tools_from_model(): + """App-only tools (_meta.ui.visibility ["app"] without "model") must not be + exposed to the model, mirroring the Go ADK filter. Model-visible app tools + are kept and recorded for result compaction.""" + model_app_tool = _make_mcp_tool("get_weather", visibility=["model", "app"], resource_uri="ui://w/dash") + app_only_tool = _make_mcp_tool("refresh_dashboard", visibility=["app"], resource_uri="ui://w/dash") + plain_tool = _make_mcp_tool("echo") # no visibility -> model-visible by default + + app_tool_names = MCPAppToolNames() + toolset = KAgentMcpToolset.__new__(KAgentMcpToolset) + toolset._app_tool_names = app_tool_names + + async def mock_super_get_tools(self_arg, readonly_context=None): + return [model_app_tool, app_only_tool, plain_tool] + + with patch.object(McpToolset, "get_tools", mock_super_get_tools): + tools = await toolset.get_tools() + + names = {t.name for t in tools} + assert "refresh_dashboard" not in names + assert names == {"get_weather", "echo"} + # Model-visible app tool is recorded; app-only tool is not. + assert "get_weather" in app_tool_names + assert "refresh_dashboard" not in app_tool_names diff --git a/ui/package-lock.json b/ui/package-lock.json index 545766788a..530ef0275d 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,59 @@ "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/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -8028,6 +8287,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 +8329,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 +8342,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 +8912,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 +8955,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 +8971,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 +8999,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 +9395,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 +9499,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 +9527,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 +9570,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 +9698,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 +9707,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 +9750,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 +9856,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 +10360,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 +10383,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 +10478,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 +10593,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 +10656,22 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "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 +10743,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 +10896,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 +10919,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 +11052,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 +11095,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 +11278,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 +11381,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 +11515,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 +11554,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 +11757,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 +11790,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 +12255,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 +12465,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 +13778,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 +14656,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 +14943,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 +15843,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 +16034,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 +16152,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 +16446,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 +16479,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 +16586,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 +16991,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", @@ -16410,13 +17050,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, + "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": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -16445,6 +17085,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 +17551,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 +17737,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 +17895,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 +17926,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 +18052,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 +18120,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 +18132,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 +18157,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 +18176,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 +18194,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 +18487,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 +19278,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 +19627,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 +19936,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 +20149,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 +20545,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 +20779,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 +20953,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..77d7e3f13e 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", @@ -64,7 +67,9 @@ "braces": ">=3.0.3", "micromatch": ">=4.0.8", "diff": ">=8.0.3", - "tar": ">=7.5.7" + "tar": ">=7.5.7", + "fast-uri": "^3.1.3", + "qs": ">=6.15.2" }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", 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..d829b1bb62 --- /dev/null +++ b/ui/public/sandbox_proxy.html @@ -0,0 +1,92 @@ + + + + + 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..25b524eb2b --- /dev/null +++ b/ui/src/app/actions/__tests__/mcp-apps.test.ts @@ -0,0 +1,105 @@ +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("appends groupKind so the backend resolves the right CRD", async () => { + await listMcpAppTools("kagent", "kanban-mcp", "MCPServer.kagent.dev"); + + expect(mockedFetchApi).toHaveBeenCalledWith( + "/mcp-apps/kagent/kanban-mcp/tools?groupKind=MCPServer.kagent.dev" + ); + }); + + it("appends groupKind on tool calls", async () => { + await callMcpAppTool("kagent", "kanban-mcp", "refresh", undefined, "RemoteMCPServer.kagent.dev"); + + expect(mockedFetchApi).toHaveBeenCalledWith( + "/mcp-apps/kagent/kanban-mcp/tools/refresh/call?groupKind=RemoteMCPServer.kagent.dev", + { + method: "POST", + body: JSON.stringify({ arguments: {} }), + } + ); + }); + + it("appends groupKind after the resource uri query", async () => { + await readMcpAppResource("kagent", "kanban-mcp", "ui://board", "MCPServer.kagent.dev"); + + expect(mockedFetchApi).toHaveBeenCalledWith( + "/mcp-apps/kagent/kanban-mcp/resources?uri=ui%3A%2F%2Fboard&groupKind=MCPServer.kagent.dev" + ); + }); + + 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..8907fae48a --- /dev/null +++ b/ui/src/app/actions/mcp-apps.ts @@ -0,0 +1,71 @@ +"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)}`; +} + +// A namespace/name pair is ambiguous across the two tool-server CRDs +// (RemoteMCPServer and MCPServer). When known, the caller passes the selected +// server's groupKind so the backend resolves the exact CRD the user intended. +function withGroupKind(path: string, groupKind?: string): string { + if (!groupKind) { + return path; + } + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}groupKind=${encodeURIComponent(groupKind)}`; +} + +export async function listMcpAppTools(namespace: string, name: string, groupKind?: string): Promise> { + try { + return await fetchApi>(withGroupKind(`${serverPath(namespace, name)}/tools`, groupKind)); + } catch (err) { + return createErrorResponse(err, "Failed to list MCP app tools"); + } +} + +export async function callMcpAppTool( + namespace: string, + name: string, + toolName: string, + args?: Record, + groupKind?: string, +): Promise> { + try { + return await fetchApi>( + withGroupKind(`${serverPath(namespace, name)}/tools/${encodeURIComponent(toolName)}/call`, groupKind), + { + 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, + groupKind?: string, +): Promise> { + try { + return await fetchApi>( + withGroupKind(`${serverPath(namespace, name)}/resources?uri=${encodeURIComponent(uri)}`, groupKind), + ); + } catch (err) { + return createErrorResponse(err, "Failed to read MCP app resource"); + } +} diff --git a/ui/src/app/apps/[appName]/page.tsx b/ui/src/app/apps/[appName]/page.tsx new file mode 100644 index 0000000000..fea1dff664 --- /dev/null +++ b/ui/src/app/apps/[appName]/page.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Suspense } from "react"; +import Link from "next/link"; +import { useParams, useSearchParams } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; +import { AppPageFrame } from "@/components/layout/AppPageFrame"; +import { McpAppsInspector } from "@/components/mcp-apps/McpAppsInspector"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +function SingleMcpAppContent() { + const params = useParams<{ appName: string }>(); + const searchParams = useSearchParams(); + + const appName = params?.appName ? decodeURIComponent(params.appName) : ""; + const namespace = searchParams.get("ns") || ""; + const server = searchParams.get("server") || ""; + + return ( + + + + Back to MCP & tools + + +

+ MCP App {appName} +

+ + {!namespace || !server || !appName ? ( + + Missing app context + + This page needs a server reference. Open an MCP App from the MCP & tools list. + + + ) : ( + + )} +
+ ); +} + +export default function McpAppPage() { + return ( + + + + ); +} diff --git a/ui/src/components/ToolDisplay.tsx b/ui/src/components/ToolDisplay.tsx index 76b436079d..5964abad8b 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)}
+              
)}
@@ -253,7 +263,7 @@ const ToolDisplay = ({ call, result, status = "requested", isError = false, isDe
)} -
+
{status === "executing" && !hasResult && (
@@ -261,26 +271,40 @@ 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 d08df624eb..0bb00006bb 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 || !onSubmit) 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 f7624b4e7b..174bb362bf 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"; @@ -27,19 +28,28 @@ import { getUiRuntimeConfig } from "@/app/actions/config"; import { DEFAULT_STREAM_TIMEOUT_MS } from "@/lib/constants"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; -import { createMessageHandlers, extractMessagesFromTasks, extractApprovalMessagesFromTasks, extractTokenStatsFromTasks, createMessage, countSendGuardComparableMessages, countBackendBackedComparableMessages, ADKMetadata, ProcessedToolCallData } from "@/lib/messageHandlers"; +import { createMessageHandlers, extractMessagesFromTasks, extractApprovalMessagesFromTasks, extractTokenStatsFromTasks, createMessage, ADKMetadata, ProcessedToolCallData } from "@/lib/messageHandlers"; import { kagentA2AClient } from "@/lib/a2aClient"; import { formatA2AClientError } from "@/lib/a2aErrors"; import { useChatRunInSandbox, useChatSubstrateSandbox } from "@/components/chat/ChatAgentContext"; 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"]; +// Server-authoritative high-water mark for cross-tab staleness detection. +// Counts persisted history messages across all tasks — a value the DB assigns +// and every tab reads identically, independent of how each tab rendered them +// (synthetic tool/artifact/summary cards never land here). Comparing this against +// the count a tab last synced reliably detects when another tab advanced the +// conversation, without depending on fragile rendered-message-count parity. +const countServerMessages = (tasks: Task[]): number => + tasks.reduce((sum, task) => sum + (task.history?.length ?? 0), 0); interface ChatInterfaceProps { selectedAgentName: string; @@ -52,6 +62,7 @@ interface ChatInterfaceProps { export default function ChatInterface({ selectedAgentName, selectedNamespace, selectedSession, sessionId, shareToken }: ChatInterfaceProps) { const runInSandbox = useChatRunInSandbox(); + const { getMcpAppForTool } = useChatMcpApps(); const substrateSandbox = useChatSubstrateSandbox(); const router = useRouter(); const containerRef = useRef(null); @@ -81,6 +92,29 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se // Stream inactivity timeout (ms), configurable via Helm (ui.streamTimeoutSeconds). const streamTimeoutMsRef = useRef(DEFAULT_STREAM_TIMEOUT_MS); + // Count of server history messages this tab has incorporated. Updated wherever + // the tab consumes server state (DB load/reload, end of a stream); the send + // guard blocks when the server has advanced past it (another tab acted). + const syncedServerMsgCountRef = useRef(0); + + // Single place that computes the high-water mark, so every update site stays + // consistent. Accepts the raw server Task[] (artifacts/synthetic cards are + // intentionally ignored — only persisted history counts). + const setServerMark = (tasks: Task[] | undefined) => { + syncedServerMsgCountRef.current = countServerMessages(tasks ?? []); + }; + + // Re-read the server's current count and advance the mark. Best-effort: a + // failed/stale read only risks a benign reload on the next send. + const refreshServerMark = async (markSessionId: string) => { + try { + const res = await getSessionTasks(markSessionId); + if (res.data) setServerMark(res.data); + } catch { + // Leave the mark as-is. + } + }; + useEffect(() => { let cancelled = false; getUiRuntimeConfig() @@ -138,6 +172,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se pendingDecisionsRef.current = {}; pendingRejectionReasonsRef.current = {}; pendingTurnStatsRef.current = undefined; + syncedServerMsgCountRef.current = 0; // Skip completely if this is a first message session creation flow if (isFirstMessage || isCreatingSessionRef.current) { @@ -210,6 +245,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se ); } } + setServerMark(messagesResponse.data); } catch (error) { console.error("Error loading messages:", error); toast.error("Error loading messages"); @@ -239,20 +275,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; @@ -260,11 +306,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se // the full context before their next message goes out. const guardSessionId = session?.id || sessionId; if (guardSessionId) { - // Compare visible messages against the backend snapshot. Completed - // same-tab stream messages remain in streamingMessages until the next - // send promotes them, but only backend-backed matches count as local. const guardResult = await checkAndSyncSessionBeforeAction(guardSessionId, { - localMessages: allMessages, messages: { inFlight: "This session is already being processed — reconnecting to live updates", inputRequired: "Session is awaiting your input — please review before sending", @@ -323,9 +365,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) { @@ -407,12 +450,41 @@ 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) => { let timeoutTimer: NodeJS.Timeout | null = null; let streamActive = true; @@ -462,6 +534,7 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se if (!currentSessionId) return; const latest = await getSessionTasks(currentSessionId, shareToken); if (latest.data && latest.data.length > 0) { + setServerMark(latest.data); const extractedMessages = extractMessagesFromTasks(latest.data); const { messages: pendingApprovalMessages, hasPendingApproval } = extractApprovalMessagesFromTasks(latest.data); setStoredMessages( @@ -543,6 +616,13 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se ); await consumeStream(stream); + + // The turn this tab just sent is now persisted; advance our high-water mark + // to the server's post-turn count so the next send's guard doesn't mistake + // our own new messages for another tab's changes. Best-effort, no reload. + if (sid) { + await refreshServerMark(sid); + } } catch (error: unknown) { if (error instanceof Error && error.name === "AbortError") { setChatStatus("ready"); @@ -608,15 +688,14 @@ 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, opts: { expectedTaskId?: string; - localMessages?: Message[]; - localMessageCount?: number; messages: { inFlight: string; inputRequired?: string; @@ -667,17 +746,13 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se return "blocked"; } - if (opts.localMessages !== undefined || opts.localMessageCount !== undefined) { - const dbMessages = extractMessagesFromTasks(tasksCheck.data); - const dbMessageCount = countSendGuardComparableMessages(dbMessages); - const localMessageCount = opts.localMessages !== undefined - ? countBackendBackedComparableMessages(opts.localMessages, dbMessages) - : opts.localMessageCount; - if (localMessageCount !== undefined && dbMessageCount > localMessageCount) { - await reloadSessionFromDB(); - toast.info(opts.messages.staleOrChanged); - return "blocked"; - } + // Send-guard mode: no specific task to verify. If the server holds more + // persisted messages than this tab has synced, another tab advanced the + // conversation — reload and block so the user sees the latest context first. + if (countServerMessages(tasksCheck.data) > syncedServerMsgCountRef.current) { + await reloadSessionFromDB(); + toast.info(opts.messages.staleOrChanged); + return "blocked"; } return "proceed"; @@ -966,10 +1041,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 && ( - +
+ +
)} )}
+
-
+
{shareReadOnly ? (

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"); +} + +// groupKind of the tool's backing server CRD, used to disambiguate the MCP Apps +// endpoint when a RemoteMCPServer and MCPServer share a namespace/name. +function serverGroupKind(tool: Tool): string { + const kind = tool.mcpServer?.kind || "RemoteMCPServer"; + const apiGroup = tool.mcpServer?.apiGroup || "kagent.dev"; + return apiGroup ? `${kind}.${apiGroup}` : kind; +} + +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, + groupKind: serverGroupKind(tool), + 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, server.groupKind); + 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, + groupKind: server.groupKind, + 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 25e4524617..e21282ca74 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__/ChatInterface.sendGuard.test.tsx b/ui/src/components/chat/__tests__/ChatInterface.sendGuard.test.tsx index 5a301b447c..6c652b8462 100644 --- a/ui/src/components/chat/__tests__/ChatInterface.sendGuard.test.tsx +++ b/ui/src/components/chat/__tests__/ChatInterface.sendGuard.test.tsx @@ -3,7 +3,7 @@ */ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { DataPart, Message, Task, TaskStatusUpdateEvent } from "@a2a-js/sdk"; +import type { Message, Task, TaskStatusUpdateEvent } from "@a2a-js/sdk"; import { checkSessionExists, createSession, getSessionTasks } from "@/app/actions/sessions"; import { kagentA2AClient } from "@/lib/a2aClient"; import { toast } from "sonner"; @@ -76,9 +76,14 @@ const mockToastInfo = toast.info as jest.MockedFunction; const staleToastMessage = "New messages loaded — please review before sending"; -function mockBackendTasks(tasks: Task[]) { - mockGetSessionTasks.mockResolvedValue({ data: tasks }); -} +// The send guard is server-authoritative: it compares the count of persisted +// history messages across all tasks (the high-water mark) against the count this +// tab last synced. These helpers build tasks whose `history.length` drives that +// count — the message content is irrelevant to the guard. + +// The backend snapshot the mocked getSessionTasks currently returns. The stream +// generators advance it to model a turn being persisted after it streams. +let currentTasks: Task[] = []; function textMessage(messageId: string, role: "user" | "agent", text: string, contextId = "session-1", taskId = "task-1"): Message { return { @@ -92,7 +97,8 @@ function textMessage(messageId: string, role: "user" | "agent", text: string, co } as Message; } -function completedTask(taskId: string, history: Message[], contextId = "session-1"): Task { +/** A completed task whose history (a user + agent turn) contributes 2 to the mark. */ +function completedTurnTask(taskId: string, prompt: string, answer: string, contextId = "session-1"): Task { return { id: taskId, contextId, @@ -100,7 +106,10 @@ function completedTask(taskId: string, history: Message[], contextId = "session- state: "completed", timestamp: new Date().toISOString(), }, - history, + history: [ + textMessage(`${taskId}-user`, "user", prompt, contextId, taskId), + textMessage(`${taskId}-agent`, "agent", answer, contextId, taskId), + ], } as Task; } @@ -118,42 +127,12 @@ function completedStatusEvent(text: string, contextId = "session-1", taskId = "t } as TaskStatusUpdateEvent; } -function toolCallMessage(messageId: string, contextId = "session-1", taskId = "task-tool", callId = "shared-call"): Message { - return { - kind: "message", - messageId, - role: "agent", - contextId, - taskId, - parts: [ - { - kind: "data", - data: { id: callId, name: "kubectl_get_pods", args: { namespace: "default" } }, - metadata: { adk_type: "function_call" }, - } as DataPart, - ], - metadata: { timestamp: Date.now() }, - } as Message; -} - -function completedToolCallStatusEvent(contextId = "session-1", taskId = "task-streamed", callId = "shared-call"): TaskStatusUpdateEvent { - return { - kind: "status-update", - contextId, - taskId, - final: true, - status: { - state: "completed", - timestamp: new Date().toISOString(), - message: toolCallMessage(`tool-${taskId}`, contextId, taskId, callId), - }, - } as TaskStatusUpdateEvent; -} - -async function* streamOf(...events: unknown[]): AsyncIterable { +/** Yields the given events, then advances the backend snapshot as if the turn was persisted. */ +async function* streamThenPersist(events: unknown[], persistedTasks: Task[]): AsyncIterable { for (const event of events) { yield event; } + currentTasks = persistedTasks; } function sessionFixture(overrides: Partial = {}): Session { @@ -189,242 +168,79 @@ async function sendText(text: string) { await user.click(screen.getByRole("button", { name: /send/i })); } -function sentMessage(callIndex = 0): Message { - return (mockSendMessageStream.mock.calls[callIndex][2] as { message: Message }).message; -} - -describe("ChatInterface send guard", () => { - const initialTurn = [ - textMessage("initial-user", "user", "initial user", "session-1", "task-initial"), - textMessage("initial-agent", "agent", "initial answer", "session-1", "task-initial"), - ]; - const sameTabTurn = [ - textMessage("same-tab-user", "user", "same tab question", "session-1", "task-streamed"), - textMessage("same-tab-agent", "agent", "same tab answer", "session-1", "task-streamed"), - ]; - const externalTurn = [ - textMessage("external-user", "user", "external user", "session-1", "task-external"), - textMessage("external-agent", "agent", "external answer", "session-1", "task-external"), - ]; +describe("ChatInterface send guard (high-water mark)", () => { + const initialTasks = () => [completedTurnTask("task-initial", "initial user", "initial answer")]; beforeEach(() => { jest.clearAllMocks(); mockCheckSessionExists.mockResolvedValue({ data: true }); mockCreateSession.mockResolvedValue({ error: "unexpected createSession call" }); + // Every getSessionTasks (load, guard, refreshServerMark, reload) reads the + // current backend snapshot; streams mutate it to simulate persistence. + mockGetSessionTasks.mockImplementation(async () => ({ data: currentTasks })); }); - it("does not block the next send when completed same-tab stream messages are already visible", async () => { - mockBackendTasks([completedTask("task-initial", initialTurn)]); + it("does not block the next send after a same-tab turn advances the mark", async () => { + currentTasks = initialTasks(); + const afterFirstTurn = [...initialTasks(), completedTurnTask("task-streamed", "same tab question", "same tab answer")]; mockSendMessageStream - .mockResolvedValueOnce(streamOf(completedStatusEvent("same tab answer"))) - .mockResolvedValueOnce(streamOf(completedStatusEvent("next answer", "session-1", "task-next"))); + .mockResolvedValueOnce(streamThenPersist([completedStatusEvent("same tab answer")], afterFirstTurn)) + .mockResolvedValueOnce(streamThenPersist([completedStatusEvent("next answer", "session-1", "task-next")], afterFirstTurn)); renderExistingSession(); expect(await screen.findByText("initial answer")).toBeInTheDocument(); + // Load synced the mark to the initial history count. + await waitFor(() => expect(mockGetSessionTasks).toHaveBeenCalledTimes(1)); await sendText("same tab question"); await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(1)); expect(await screen.findByText("same tab answer")).toBeInTheDocument(); + // Wait until refreshServerMark has re-read the post-turn snapshot (load + + // guard + refresh = 3 reads) so the mark reflects our own new messages. + await waitFor(() => expect(mockGetSessionTasks).toHaveBeenCalledTimes(3)); - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-streamed", [ - textMessage(sentMessage().messageId, "user", "same tab question", "session-1", "task-streamed"), - textMessage("same-tab-agent", "agent", "same tab answer", "session-1", "task-streamed"), - ]), - ]); await sendText("next question"); await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(2)); expect(mockToastInfo).not.toHaveBeenCalledWith(staleToastMessage); }); - it("still blocks after a same-tab stream when the backend also has an unseen cross-tab message", async () => { - mockBackendTasks([completedTask("task-initial", initialTurn)]); - mockSendMessageStream - .mockResolvedValueOnce(streamOf(completedStatusEvent("same tab answer"))); + it("blocks the send when another tab advanced the conversation past the synced mark", async () => { + currentTasks = initialTasks(); renderExistingSession(); expect(await screen.findByText("initial answer")).toBeInTheDocument(); + await waitFor(() => expect(mockGetSessionTasks).toHaveBeenCalledTimes(1)); - await sendText("same tab question"); - await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(1)); - expect(await screen.findByText("same tab answer")).toBeInTheDocument(); + // Another tab added a turn the server persisted but this tab never synced. + currentTasks = [...initialTasks(), completedTurnTask("task-external", "external user", "external answer")]; - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-streamed", sameTabTurn), - completedTask("task-external", externalTurn), - ]); await sendText("should review cross-tab first"); await waitFor(() => expect(mockToastInfo).toHaveBeenCalledWith(staleToastMessage)); - expect(mockSendMessageStream).toHaveBeenCalledTimes(1); - }); - - it("does not let local-only streaming messages mask an unseen backend turn", async () => { - mockBackendTasks([completedTask("task-initial", initialTurn)]); - mockSendMessageStream - .mockResolvedValueOnce(streamOf(completedStatusEvent("local-only answer"))); - - renderExistingSession(); - - expect(await screen.findByText("initial answer")).toBeInTheDocument(); - - await sendText("local optimistic question"); - await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(1)); - expect(await screen.findByText("local-only answer")).toBeInTheDocument(); - - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-external", externalTurn), - ]); - await sendText("should review backend first"); - - await waitFor(() => expect(mockToastInfo).toHaveBeenCalledWith(staleToastMessage)); - expect(mockSendMessageStream).toHaveBeenCalledTimes(1); - }); - - it("blocks when local-only streaming messages share text and role with an unseen backend turn", async () => { - mockBackendTasks([completedTask("task-initial", initialTurn)]); - mockSendMessageStream - .mockResolvedValueOnce(streamOf(completedStatusEvent("duplicate answer", "session-1", "task-streamed"))); - - renderExistingSession(); - - expect(await screen.findByText("initial answer")).toBeInTheDocument(); - - await sendText("duplicate question"); - await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(1)); - expect(await screen.findByText("duplicate answer")).toBeInTheDocument(); - - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-unseen", [ - textMessage("unseen-user", "user", "duplicate question", "session-1", "task-unseen"), - textMessage("unseen-agent", "agent", "duplicate answer", "session-1", "task-unseen"), - ]), - ]); - await sendText("should review duplicate backend first"); - - await waitFor(() => expect(mockToastInfo).toHaveBeenCalledWith(staleToastMessage)); - expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + expect(mockSendMessageStream).not.toHaveBeenCalled(); + // The block reloaded the latest context for the user. + expect(await screen.findByText("external answer")).toBeInTheDocument(); }); - it("blocks when a local-only empty tool stream collides with an unseen backend data turn", async () => { - mockBackendTasks([completedTask("task-initial", initialTurn)]); - mockSendMessageStream - .mockResolvedValueOnce(streamOf(completedToolCallStatusEvent("session-1", "task-streamed", "shared-call"))); + it("proceeds after a block once the reload re-syncs the mark", async () => { + currentTasks = initialTasks(); + mockSendMessageStream.mockResolvedValueOnce(streamThenPersist([completedStatusEvent("ok")], currentTasks)); renderExistingSession(); - expect(await screen.findByText("initial answer")).toBeInTheDocument(); - await sendText("local tool question"); - await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(1)); - - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-unseen-tool", [ - toolCallMessage("backend-tool", "session-1", "task-unseen-tool", "shared-call"), - ]), - ]); - await sendText("should review tool turn first"); - + // First send is stale and blocked; reloadSessionFromDB advances the mark. + currentTasks = [...initialTasks(), completedTurnTask("task-external", "external user", "external answer")]; + await sendText("first try"); await waitFor(() => expect(mockToastInfo).toHaveBeenCalledWith(staleToastMessage)); - expect(mockSendMessageStream).toHaveBeenCalledTimes(1); - }); - - it("does not block the next send after a same-tab tool-call turn is persisted", async () => { - // Reproduces the false positive seen with tool calls: a same-tab turn that - // makes a tool call streams a ToolCallRequestEvent locally (keyed by its - // real contextId/taskId), but the backend persists agent messages with - // EMPTY contextId/taskId. extractMessagesFromTasks leaves those empty - // (`"" ?? task.contextId` is still "") and rebuilds the converted tool - // message with a fresh uuidv4 messageId, so it keys on ["message", ] - // — a different key on every extraction. The guard therefore can never match - // the persisted tool message against the local one, counts the backend as - // ahead, and falsely blocks the next send. - mockBackendTasks([completedTask("task-initial", initialTurn)]); - mockSendMessageStream - .mockResolvedValueOnce(streamOf(completedToolCallStatusEvent("session-1", "task-streamed", "shared-call"))) - .mockResolvedValueOnce(streamOf(completedStatusEvent("next answer", "session-1", "task-next"))); - - renderExistingSession(); - - expect(await screen.findByText("initial answer")).toBeInTheDocument(); - - await sendText("tool question"); - await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(1)); - - // Backend now reflects exactly that same-tab tool turn. The persisted agent - // tool message carries empty contextId/taskId, as the real backend stores it. - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-streamed", [ - textMessage(sentMessage().messageId, "user", "tool question", "session-1", "task-streamed"), - toolCallMessage("backend-tool", "", "", "shared-call"), - ]), - ]); - await sendText("next question"); - - await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(2)); - expect(mockToastInfo).not.toHaveBeenCalledWith(staleToastMessage); - }); - - it("does not block the next send after a same-tab text turn persisted with empty agent ids", async () => { - // The general case behind "every message needs sending twice": the backend - // persists AGENT messages with empty contextId/taskId (A2A optional fields; - // the task is the canonical carrier). The locally-streamed agent message - // carries the task's real ids, so it keys on ["task", ...] while the - // backend-extracted copy — pushed as-is with empty ids — keys on - // ["message", ]. They never match, so every turn (each has an - // agent text response) counts the backend as ahead and falsely blocks. - mockBackendTasks([completedTask("task-initial", initialTurn)]); - mockSendMessageStream - .mockResolvedValueOnce(streamOf(completedStatusEvent("same tab answer", "session-1", "task-streamed"))) - .mockResolvedValueOnce(streamOf(completedStatusEvent("next answer", "session-1", "task-next"))); - - renderExistingSession(); - - expect(await screen.findByText("initial answer")).toBeInTheDocument(); + expect(mockSendMessageStream).not.toHaveBeenCalled(); - await sendText("same tab question"); + // Nothing else changed on the server, so the next send goes through. + await sendText("second try"); await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(1)); - expect(await screen.findByText("same tab answer")).toBeInTheDocument(); - - // Backend reflects the turn, with the agent message persisted with empty - // contextId/taskId (the user message keeps its contextId, as the client stamps it). - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-streamed", [ - textMessage(sentMessage().messageId, "user", "same tab question", "session-1", ""), - textMessage("same-tab-agent", "agent", "same tab answer", "", ""), - ]), - ]); - await sendText("next question"); - - await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalledTimes(2)); - expect(mockToastInfo).not.toHaveBeenCalledWith(staleToastMessage); - }); - - it("still blocks when the backend has a cross-tab message not visible locally", async () => { - mockBackendTasks([completedTask("task-initial", initialTurn)]); - - renderExistingSession(); - - expect(await screen.findByText("initial answer")).toBeInTheDocument(); - - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-external", externalTurn), - ]); - await sendText("should review first"); - - await waitFor(() => expect(mockToastInfo).toHaveBeenCalledWith(staleToastMessage)); - expect(mockSendMessageStream).not.toHaveBeenCalled(); }); it.each([ @@ -432,16 +248,14 @@ describe("ChatInterface send guard", () => { ["Ctrl+Enter", { ctrlKey: true }], ])("applies the stale-message send guard for %s", async (_shortcut, modifier) => { const user = userEvent.setup(); - mockBackendTasks([completedTask("task-initial", initialTurn)]); + currentTasks = initialTasks(); renderExistingSession(); expect(await screen.findByText("initial answer")).toBeInTheDocument(); + await waitFor(() => expect(mockGetSessionTasks).toHaveBeenCalledTimes(1)); - mockBackendTasks([ - completedTask("task-initial", initialTurn), - completedTask("task-external", externalTurn), - ]); + currentTasks = [...initialTasks(), completedTurnTask("task-external", "external user", "external answer")]; const textbox = screen.getByRole("textbox"); await user.type(textbox, "should review first"); 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..1f2179b7ad --- /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", "RemoteMCPServer.kagent.dev"); + }); + + 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..e82dfd5108 --- /dev/null +++ b/ui/src/components/mcp-apps/McpAppRenderer.tsx @@ -0,0 +1,237 @@ +"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(); + +type SandboxCsp = NonNullable["csp"]; + +interface McpAppRendererProps { + namespace: string; + serverName: string; + /** + * groupKind of the backing tool-server CRD so proxied tool/resource calls + * resolve the right CRD when a RemoteMCPServer and MCPServer share a + * namespace/name. + */ + groupKind?: 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, + groupKind, + toolName, + toolResourceUri, + toolInput, + toolResult, + onSendMessage, +}: McpAppRendererProps) { + const { resolvedTheme } = useTheme(); + const { getMcpToolForAppCall } = useChatMcpApps(); + const [sandboxUrl, setSandboxUrl] = useState(null); + const [csp, setCsp] = useState(undefined); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const id = requestAnimationFrame(() => { + // ponytail: the proxy is served from the host origin, so with the iframe's + // allow-same-origin attribute an MCP App shares the host origin (can reach + // host cookies/storage/DOM/API). Accepted by design: MCP servers are + // curated/trusted, and a single origin keeps everything behind one SSO + // gateway. Upgrade path if untrusted servers are ever exposed: serve + // sandbox_proxy.html from a distinct, env-configured origin so + // allow-same-origin resolves to the sandbox origin instead of the host. + // 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], + ); + + // Pass the resource's declared CSP (captured in handleReadResource) to the + // sandbox proxy so it can enforce it; the proxy applies the spec's restrictive + // default when this is undefined. + const sandbox = useMemo(() => sandboxUrl ? { url: sandboxUrl, csp } : undefined, [sandboxUrl, csp]); + + // 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 }) => { + const result = requireData(await readMcpAppResource(namespace, serverName, uri, groupKind)); + // Capture _meta.ui.csp before the library renders the iframe so the sandbox + // proxy can enforce the server-declared Content Security Policy. + const ui = (result.contents?.[0]?._meta as { ui?: { csp?: SandboxCsp } } | undefined)?.ui; + if (ui?.csp) { + setCsp(ui.csp); + } + return result; + }, [namespace, serverName, groupKind]); + + // 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, groupKind)); + }, [getMcpToolForAppCall, namespace, serverName, toolName, groupKind]); + + // 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-apps/McpAppsInspector.tsx b/ui/src/components/mcp-apps/McpAppsInspector.tsx new file mode 100644 index 0000000000..4627725f5a --- /dev/null +++ b/ui/src/components/mcp-apps/McpAppsInspector.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { AlertCircle, Loader2, Play } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { + callMcpAppTool, + listMcpAppTools, + type McpAppTool, +} from "@/app/actions/mcp-apps"; +import { McpAppRenderer } from "./McpAppRenderer"; + +interface McpAppsInspectorProps { + namespace: string; + name: string; + /** When set, scope the inspector to a single app (tool) instead of listing all of them. */ + focusToolName?: string; +} + +type UiMcpAppTool = McpAppTool & { uiResourceUri: string }; + +export function McpAppsInspector({ namespace, name, focusToolName }: McpAppsInspectorProps) { + const [tools, setTools] = useState([]); + const [selectedToolName, setSelectedToolName] = useState(""); + const [argsText, setArgsText] = useState("{}"); + const [toolResult, setToolResult] = useState(); + const [toolInput, setToolInput] = useState | undefined>(); + const [loading, setLoading] = useState(true); + const [invoking, setInvoking] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function loadTools() { + setLoading(true); + setError(null); + const response = await listMcpAppTools(namespace, name); + if (cancelled) { + return; + } + if (response.error || !response.data) { + setError(response.error || response.message); + setTools([]); + } else { + const uiTools = response.data.filter((tool): tool is UiMcpAppTool => !!tool.uiResourceUri); + const scoped = focusToolName ? uiTools.filter((tool) => tool.name === focusToolName) : uiTools; + setTools(scoped); + setSelectedToolName((current) => current || scoped[0]?.name || ""); + } + setLoading(false); + } + void loadTools(); + return () => { + cancelled = true; + }; + }, [namespace, name, focusToolName]); + + const selectedTool = useMemo( + () => tools.find((tool) => tool.name === selectedToolName), + [selectedToolName, tools], + ); + + const invokeSelectedTool = async () => { + if (!selectedTool) { + return; + } + let args: Record; + try { + const parsed = JSON.parse(argsText || "{}") as unknown; + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error("Tool arguments must be a JSON object"); + } + args = parsed as Record; + } catch (err) { + setError(err instanceof Error ? err.message : "Invalid JSON arguments"); + return; + } + + setInvoking(true); + setError(null); + setToolResult(undefined); + setToolInput(args); + const response = await callMcpAppTool(namespace, name, selectedTool.name, args); + if (response.error || !response.data) { + setError(response.error || response.message); + } else { + setToolResult(response.data); + } + setInvoking(false); + }; + + return ( +
+
+

MCP Apps

+

+ Inspect UI-capable tools exposed by {namespace}/{name}. +

+
+ + {error ? ( + + + Error + {error} + + ) : null} + + + + UI-capable tools + + Tools are listed only when they declare _meta.ui.resourceUri or the legacy alias. + + + + {loading ? ( +
+ + Loading MCP Apps... +
+ ) : tools.length === 0 ? ( +
+ No MCP Apps found for this server. +
+ ) : ( +
+ {tools.map((tool) => ( + + ))} +
+ )} +
+
+ + {selectedTool ? ( + + + Invoke {selectedTool.name} + Enter JSON arguments, invoke the tool, then render its MCP App resource. + + +