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