From f3971bbd56e4aadea0f8b0c1434f6860f953cbbd Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Sun, 12 Jul 2026 01:04:38 -0400 Subject: [PATCH] feat(mcp): support sandboxed MCP Apps (#69039) Implement stable opt-in MCP Apps negotiation, caller-specific tool visibility, bounded ephemeral UI resources, a dedicated-origin double iframe host, restrictive CSP, configuration, docs, and regression tests. Co-authored-by: Peter Steinberger Co-authored-by: Sally O'Malley <11166065+sallyom@users.noreply.github.com> --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/cli/mcp.md | 54 +++ docs/docs_map.md | 1 + pnpm-lock.yaml | 29 ++ src/agents/agent-bundle-mcp-materialize.ts | 25 +- src/agents/agent-bundle-mcp-runtime.test.ts | 125 ++++++- src/agents/agent-bundle-mcp-runtime.ts | 153 +++++++- ...agent-bundle-mcp-tools.materialize.test.ts | 83 ++++- src/agents/agent-bundle-mcp-tools.ts | 1 + src/agents/agent-bundle-mcp-types.ts | 16 +- src/agents/embedded-agent-runner/run.ts | 4 + src/agents/mcp-app-sandbox.ts | 172 +++++++++ src/agents/mcp-ui-resource.test.ts | 280 +++++++++++++++ src/agents/mcp-ui-resource.ts | 299 ++++++++++++++++ src/chat/canvas-render.test.ts | 39 +- src/chat/canvas-render.ts | 23 ++ src/config/config-misc.test.ts | 33 ++ src/config/schema.help.ts | 8 + src/config/schema.labels.ts | 4 + src/config/schema.tags.ts | 3 + src/config/types.mcp.ts | 8 + src/config/zod-schema.ts | 24 ++ src/gateway/chat-display-projection.ts | 58 ++- src/gateway/control-ui-csp.test.ts | 1 + src/gateway/control-ui-csp.ts | 3 + src/gateway/control-ui.http.test.ts | 5 + src/gateway/control-ui.ts | 3 + src/gateway/mcp-app-sandbox-http.test.ts | 50 +++ src/gateway/mcp-app-sandbox-http.ts | 61 ++++ src/gateway/methods/core-descriptors.ts | 6 + src/gateway/server-methods.ts | 15 + src/gateway/server-methods/mcp-app.test.ts | 157 ++++++++ src/gateway/server-methods/mcp-app.ts | 211 +++++++++++ .../server-methods/server-methods.test.ts | 33 ++ src/gateway/server-methods/shared-types.ts | 1 + src/gateway/server-request-context.ts | 2 + src/gateway/server-runtime-state.test.ts | 25 ++ src/gateway/server-runtime-state.ts | 41 ++- src/gateway/server.impl.ts | 2 + src/gateway/server/http-listen.ts | 15 +- src/security/audit-gateway-config.ts | 12 + src/security/audit-gateway-exposure.test.ts | 9 + ui/package.json | 2 + ui/src/components/mcp-app-view.ts | 336 ++++++++++++++++++ ui/src/lib/chat/chat-types.ts | 1 + ui/src/lib/chat/message-normalizer.ts | 7 + ui/src/lib/chat/tool-cards.ts | 11 +- ui/src/pages/chat/components/chat-message.ts | 1 + .../components/chat-tool-cards.node.test.ts | 28 ++ .../chat/components/chat-tool-cards.test.ts | 99 +++++- .../pages/chat/components/chat-tool-cards.ts | 36 +- 51 files changed, 2565 insertions(+), 54 deletions(-) create mode 100644 src/agents/mcp-app-sandbox.ts create mode 100644 src/agents/mcp-ui-resource.test.ts create mode 100644 src/agents/mcp-ui-resource.ts create mode 100644 src/gateway/mcp-app-sandbox-http.test.ts create mode 100644 src/gateway/mcp-app-sandbox-http.ts create mode 100644 src/gateway/server-methods/mcp-app.test.ts create mode 100644 src/gateway/server-methods/mcp-app.ts create mode 100644 ui/src/components/mcp-app-view.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index aea70da5a42e..c7f60f7f625e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -484dbb1fe72a0c5188767bb368a0be5b1ab04566d16e8de274a8d753fd800c70 plugin-sdk-api-baseline.json -4571b249d41a808a817f3bc8b3fce4ffc26373b9078327f2188681a056a9fa21 plugin-sdk-api-baseline.jsonl +de87f2acba61514406fe343f0ee2896a101435203c4ef35af8db707b32d109f6 plugin-sdk-api-baseline.json +eb72aa8eb79d0729f12ea7715b0ff098677e06e677050009ba1add89cc357f27 plugin-sdk-api-baseline.jsonl diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index e0b4337c9dbd..69948414974d 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -823,6 +823,60 @@ Notes: - the page does not start MCP transports by itself - active runtimes may need `openclaw mcp reload`, Gateway config publish, or process restart depending on which process owns the MCP clients +## MCP Apps + +OpenClaw can render tools that implement the stable [MCP Apps extension](https://modelcontextprotocol.io/extensions/apps). Apps are opt-in because their HTML comes from the configured MCP server and can request app-visible tools or resources from that same server. + +Enable the host bridge: + +```bash +openclaw config set mcp.apps.enabled true --strict-json +``` + +Restart the Gateway after changing this setting. When enabled, OpenClaw starts a sandbox-only HTTP(S) listener on the Gateway port plus one (for the default Gateway, `18790`). The Control UI loads Apps from that separate origin; the listener never serves Control UI, authenticated Gateway routes, or user data. + +Direct Gateway connections need access to both ports. If a reverse proxy or TLS terminator exposes the Control UI, give Apps a dedicated public origin and proxy only that origin to the sandbox listener: + +```json5 +{ + mcp: { + apps: { + enabled: true, + sandboxOrigin: "https://mcp-apps.example.com", + sandboxPort: 18790, + }, + }, +} +``` + +The sandbox origin must differ from the Control UI origin. Do not host other authenticated or sensitive content on it. + +For example, the official basic React demo can be configured as: + +```json5 +{ + mcp: { + apps: { enabled: true }, + servers: { + "basic-react": { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-basic-react", "--stdio"], + }, + }, + }, +} +``` + +Behavior and security boundaries: + +- OpenClaw advertises the `io.modelcontextprotocol/ui` extension only when Apps are enabled. +- Only `ui://` resources with the exact `text/html;profile=mcp-app` MIME type render. +- UI resources are capped at 2 MiB, placed behind a double-iframe proxy on a dedicated outer origin, loaded into an opaque inner App origin, and constrained by CSP derived from the resource metadata. +- App-only tools (`_meta.ui.visibility: ["app"]`) stay out of model tool lists. Apps can call only app-visible tools on their owning server. +- Origin-bound App permissions such as camera, microphone, and geolocation are not granted while inner App documents use opaque origins for cross-App isolation. +- App HTML, complete tool arguments, and raw results live in a bounded ten-minute in-memory view lease. They are not written to disk or copied into transcript preview metadata, and an expired view does not restart its MCP runtime. +- `openclaw security audit` warns while the bridge is enabled. Disable it with `openclaw config set mcp.apps.enabled false --strict-json` when it is not needed. + ## Current limits This page documents the bridge as shipped today. diff --git a/docs/docs_map.md b/docs/docs_map.md index abb1f893a7e2..9a4fec19853a 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -1651,6 +1651,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: OAuth workflow - H3: Streamable HTTP transport - H2: Control UI + - H2: MCP Apps - H2: Current limits - H2: Related diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 106800b0e1b6..45e6d79162ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2031,6 +2031,12 @@ importers: '@lit/context': specifier: 1.1.6 version: 1.1.6 + '@modelcontextprotocol/ext-apps': + specifier: 1.7.4 + version: 1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3) + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@4.4.3) '@noble/ed25519': specifier: 3.1.0 version: 3.1.0 @@ -3184,6 +3190,20 @@ packages: '@opentelemetry/api': optional: true + '@modelcontextprotocol/ext-apps@1.7.4': + resolution: {integrity: sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==} + 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 + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -9392,6 +9412,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@mozilla/readability@0.6.0': {} '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index c976f838d463..0535be0b123f 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -19,9 +19,14 @@ import type { SessionMcpRuntime, } from "./agent-bundle-mcp-types.js"; import { mcpContentBlockToAgentContent } from "./mcp-content.js"; +import { buildMcpAppCanvasPayload, fetchMcpAppView } from "./mcp-ui-resource.js"; import type { AgentToolResult } from "./runtime/index.js"; import type { AnyAgentTool } from "./tools/common.js"; +function isAppOnlyTool(tool: McpCatalogTool): boolean { + return tool.uiVisibility !== undefined && !tool.uiVisibility.includes("model"); +} + function toAgentToolResult(params: { serverName: string; toolName: string; @@ -206,6 +211,9 @@ export function buildBundleMcpToolsFromCatalog(params: { }); for (const tool of sortedCatalogTools) { + if (isAppOnlyTool(tool)) { + continue; + } const originalName = tool.toolName.trim(); if (!originalName) { continue; @@ -358,11 +366,26 @@ export async function materializeBundleMcpToolsForRun(params: { createExecute: (tool) => async (_toolCallId: string, input: unknown) => { params.runtime.markUsed(); const result = await params.runtime.callTool(tool.serverName, tool.toolName, input); - return toAgentToolResult({ + const agentResult = toAgentToolResult({ serverName: tool.serverName, toolName: tool.toolName, result, }); + if (params.runtime.mcpAppsEnabled && tool.uiResourceUri) { + const view = await fetchMcpAppView({ + runtime: params.runtime, + serverName: tool.serverName, + toolName: tool.toolName, + uiResourceUri: tool.uiResourceUri, + toolInput: input, + toolResult: result, + }); + if (view) { + (agentResult.details as Record).mcpAppPreview = + buildMcpAppCanvasPayload(view); + } + } + return agentResult; }, createResourceListExecute: params.runtime.listResources ? (serverName) => async () => { diff --git a/src/agents/agent-bundle-mcp-runtime.test.ts b/src/agents/agent-bundle-mcp-runtime.test.ts index fa1b0edcd040..a6a53de0d579 100644 --- a/src/agents/agent-bundle-mcp-runtime.test.ts +++ b/src/agents/agent-bundle-mcp-runtime.test.ts @@ -5,9 +5,11 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { createBundleMcpJsonSchemaValidator } from "./agent-bundle-mcp-runtime.js"; import { cleanupBundleMcpHarness } from "./agent-bundle-mcp-test-harness.js"; import { + completeDeferredSessionMcpRuntimeRetirement, createSessionMcpRuntime, getOrCreateSessionMcpRuntime, materializeBundleMcpToolsForRun, @@ -28,6 +30,7 @@ vi.mock("./embedded-agent-mcp.js", () => ({ })); const tempDirs: string[] = []; +const appMetadataTempDirs = useAutoCleanupTempDirTracker(afterEach); type RuntimeFactoryOptions = NonNullable< Parameters[0] @@ -44,7 +47,12 @@ async function writeListToolsMcpServer(params: { initializeDelayMs?: number; hang?: boolean; inputSchema?: unknown; - tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; + tools?: Array<{ + name: string; + description?: string; + inputSchema?: unknown; + _meta?: Record; + }>; capabilities?: Record; pidPath?: string; notifyListChangedOnInitialized?: boolean; @@ -346,6 +354,76 @@ afterEach(async () => { }); describe("session MCP runtime", () => { + it("advertises the stable MCP Apps client extension only when enabled", () => { + expect(testing.buildMcpClientCapabilities(false)).toEqual({}); + expect(testing.buildMcpClientCapabilities(true)).toEqual({ + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: ["text/html;profile=mcp-app"], + }, + }, + }); + }); + + it("catalogs canonical and deprecated MCP App tool metadata", async () => { + const tempDir = appMetadataTempDirs.make("bundle-mcp-app-metadata-"); + const serverPath = path.join(tempDir, "app-metadata.mjs"); + const logPath = path.join(tempDir, "server.log"); + await writeListToolsMcpServer({ + filePath: serverPath, + logPath, + tools: [ + { + name: "canonical", + inputSchema: { type: "object" }, + _meta: { ui: { resourceUri: "ui://demo/app", visibility: ["app"] } }, + }, + { + name: "deprecated", + inputSchema: { type: "object" }, + _meta: { "ui/resourceUri": "ui://demo/legacy" }, + }, + { + name: "hidden", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: [] } }, + }, + ], + }); + const runtime = createSessionMcpRuntime({ + sessionId: "session-app-metadata", + workspaceDir: "/workspace", + cfg: { + mcp: { + apps: { enabled: true }, + servers: { + demo: { command: process.execPath, args: [serverPath] }, + }, + }, + }, + }); + try { + const catalog = await runtime.getCatalog(); + expect(catalog.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + toolName: "canonical", + uiResourceUri: "ui://demo/app", + uiVisibility: ["app"], + }), + expect.objectContaining({ + toolName: "deprecated", + uiResourceUri: "ui://demo/legacy", + }), + expect.objectContaining({ toolName: "hidden", uiVisibility: [] }), + ]), + ); + } finally { + await runtime.dispose(); + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("accepts draft-2020-12 tool output schemas from external MCP catalogs", () => { const validator = createBundleMcpJsonSchemaValidator().getValidator<{ format: string; @@ -1866,6 +1944,51 @@ process.on("SIGINT", shutdown);`, await expect(retireSessionMcpRuntime({ sessionId: " ", reason: "test" })).resolves.toBe(false); }); + it("preserves a runtime while a bounded app view lease is active", async () => { + const runtime = await getOrCreateSessionMcpRuntime({ + sessionId: "session-view-lease", + sessionKey: "agent:test:session-view-lease", + workspaceDir: "/workspace", + cfg: { mcp: { sessionIdleTtlMs: 0 } }, + }); + const release = runtime.acquireLease?.(); + + await expect( + retireSessionMcpRuntime({ + sessionId: "session-view-lease", + reason: "embedded-run-end", + preserveActiveLeases: true, + }), + ).resolves.toBe(true); + expect(testing.getCachedSessionIds()).toContain("session-view-lease"); + + release?.(); + await completeDeferredSessionMcpRuntimeRetirement(runtime); + expect(testing.getCachedSessionIds()).not.toContain("session-view-lease"); + }); + + it("cancels deferred retirement when a later run reuses the runtime", async () => { + const manager = testing.createSessionMcpRuntimeManager({ enableIdleSweepTimer: false }); + const params = { + sessionId: "session-reused-after-view", + sessionKey: "agent:test:session-reused-after-view", + workspaceDir: "/workspace", + cfg: { mcp: { servers: {}, sessionIdleTtlMs: 0 } }, + }; + const runtime = await manager.getOrCreate(params); + const release = runtime.acquireLease?.(); + + expect(manager.deferRetirement(params.sessionId)).toBe(true); + await expect(manager.getOrCreate(params)).resolves.toBe(runtime); + + release?.(); + await expect(manager.completeDeferredRetirement(params.sessionId, runtime)).resolves.toBe( + false, + ); + expect(manager.listSessionIds()).toContain(params.sessionId); + await manager.disposeAll(); + }); + it("retires global session runtimes by session key", async () => { await getOrCreateSessionMcpRuntime({ sessionId: "session-retire-key", diff --git a/src/agents/agent-bundle-mcp-runtime.ts b/src/agents/agent-bundle-mcp-runtime.ts index 2e6ff97b96d9..b1ca1415030a 100644 --- a/src/agents/agent-bundle-mcp-runtime.ts +++ b/src/agents/agent-bundle-mcp-runtime.ts @@ -1,9 +1,13 @@ /** Session-scoped MCP runtime manager, catalog loader, and transport lifecycle. */ import crypto from "node:crypto"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { ErrorCode, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + ErrorCode, + type CallToolResult, + type ClientCapabilities, +} from "@modelcontextprotocol/sdk/types.js"; import type { ServerCapabilities } from "@modelcontextprotocol/sdk/types.js"; import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; @@ -53,6 +57,8 @@ type CreateSessionMcpRuntime = ( ) => SessionMcpRuntime; const SESSION_MCP_RUNTIME_MANAGER_KEY = Symbol.for("openclaw.sessionMcpRuntimeManager"); +const MCP_APPS_CLIENT_EXTENSION = "io.modelcontextprotocol/ui"; +const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; const DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS = 10 * 60 * 1000; const SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000; const BUNDLE_MCP_FAILURE_THRESHOLD = 3; @@ -187,6 +193,21 @@ function setBundleMcpDisposeTimeoutMsForTest(timeoutMs?: number): void { ? Math.floor(timeoutMs) : undefined; } + +function buildMcpClientCapabilities(mcpAppsEnabled: boolean): ClientCapabilities { + return mcpAppsEnabled + ? { + extensions: { + [MCP_APPS_CLIENT_EXTENSION]: { mimeTypes: [MCP_APP_RESOURCE_MIME_TYPE] }, + }, + } + : {}; +} + +function buildMcpClientOptions(mcpAppsEnabled: boolean): ClientOptions { + return { capabilities: buildMcpClientCapabilities(mcpAppsEnabled) }; +} + async function listAllResources(client: Client, timeoutMs: number) { const resources: unknown[] = []; let cursor: string | undefined; @@ -219,6 +240,16 @@ function normalizeStringList(value: unknown): string[] | undefined { return entries.length > 0 ? entries : undefined; } +function normalizeToolUiVisibility(value: unknown): Array<"app" | "model"> | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized = value.filter( + (entry): entry is "app" | "model" => entry === "app" || entry === "model", + ); + return [...new Set(normalized)].toSorted(); +} + function getMcpToolSelection(rawServer: unknown): McpToolSelection { if (!isMcpConfigRecord(rawServer) || !isMcpConfigRecord(rawServer.toolFilter)) { return {}; @@ -301,10 +332,13 @@ async function disposeSession(session: BundleMcpSession) { } } -function createCatalogFingerprint(servers: Record): string { +function createCatalogFingerprint(params: { + servers: Record; + mcpAppsEnabled: boolean; +}): string { // Session MCP fingerprints only invalidate in-memory runtime catalogs. // Algorithm changes can cause one cache miss, but no persisted state migration. - return crypto.createHash("sha256").update(JSON.stringify(servers)).digest("hex"); + return crypto.createHash("sha256").update(JSON.stringify(params)).digest("hex"); } function loadSessionMcpConfig(params: { @@ -328,7 +362,10 @@ function loadSessionMcpConfig(params: { } return { loaded, - fingerprint: createCatalogFingerprint(loaded.mcpServers), + fingerprint: createCatalogFingerprint({ + servers: loaded.mcpServers, + mcpAppsEnabled: params.cfg?.mcp?.apps?.enabled === true, + }), }; } @@ -379,6 +416,7 @@ export function createSessionMcpRuntime(params: { logDiagnostics: true, manifestRegistry: params.manifestRegistry, }); + const mcpAppsEnabled = params.cfg?.mcp?.apps?.enabled === true; const createdAt = Date.now(); let lastUsedAt = createdAt; let activeLeases = 0; @@ -557,6 +595,7 @@ export function createSessionMcpRuntime(params: { version: "0.0.0", }, { + ...buildMcpClientOptions(mcpAppsEnabled), jsonSchemaValidator: createMcpJsonSchemaValidator(), listChanged: { tools: { @@ -660,6 +699,17 @@ export function createSessionMcpRuntime(params: { if (!toolName) { continue; } + const { _meta: metadata } = tool; + const uiMeta = + metadata?.ui && typeof metadata.ui === "object" && !Array.isArray(metadata.ui) + ? (metadata.ui as { resourceUri?: unknown; visibility?: unknown }) + : undefined; + const rawResourceUri = uiMeta?.resourceUri ?? metadata?.["ui/resourceUri"]; + const uiResourceUri = + typeof rawResourceUri === "string" && rawResourceUri.startsWith("ui://") + ? rawResourceUri + : undefined; + const uiVisibility = normalizeToolUiVisibility(uiMeta?.visibility); toolEntries.push({ serverName, safeServerName, @@ -668,6 +718,8 @@ export function createSessionMcpRuntime(params: { description: sanitizeMcpMetadataText(tool.description), inputSchema: tool.inputSchema, fallbackDescription: `Provided by bundle MCP server "${serverName}" (${resolved.description}).`, + ...(uiResourceUri ? { uiResourceUri } : {}), + ...(uiVisibility ? { uiVisibility } : {}), }); } return { @@ -777,6 +829,7 @@ export function createSessionMcpRuntime(params: { workspaceDir: params.workspaceDir, agentDir: params.agentDir, configFingerprint, + mcpAppsEnabled, createdAt, get lastUsedAt() { return lastUsedAt; @@ -821,6 +874,14 @@ export function createSessionMcpRuntime(params: { )) as CallToolResult, ); }, + async listTools(serverName, requestParams) { + failIfDisposed(); + await getCatalog(); + const session = requireConnectedSession(serverName); + return await runGuardedServerRequest(serverName, async () => + session.client.listTools(requestParams, { timeout: session.requestTimeoutMs }), + ); + }, async listResources(serverName) { failIfDisposed(); await getCatalog(); @@ -839,6 +900,16 @@ export function createSessionMcpRuntime(params: { await session.client.readResource({ uri }, { timeout: session.requestTimeoutMs }), ); }, + async listResourceTemplates(serverName, requestParams) { + failIfDisposed(); + await getCatalog(); + const session = requireConnectedSession(serverName); + return await runGuardedServerRequest(serverName, async () => + session.client.listResourceTemplates(requestParams, { + timeout: session.requestTimeoutMs, + }), + ); + }, async listPrompts(serverName) { failIfDisposed(); await getCatalog(); @@ -885,6 +956,7 @@ function createSessionMcpRuntimeManager( const runtimesBySessionId = new Map(); const sessionIdBySessionKey = new Map(); const idleTtlMsBySessionId = new Map(); + const deferredRetirementSessionIds = new Set(); const createRuntime = opts.createRuntime ?? createSessionMcpRuntime; const now = opts.now ?? Date.now; const createInFlight = new Map< @@ -922,6 +994,7 @@ function createSessionMcpRuntimeManager( } runtimesBySessionId.delete(sessionId); idleTtlMsBySessionId.delete(sessionId); + deferredRetirementSessionIds.delete(sessionId); forgetSessionKeysForSessionId(sessionId); expired.push(runtime); } @@ -959,6 +1032,24 @@ function createSessionMcpRuntimeManager( idleSweepTimer = undefined; }; + const disposeManagedSession = async (sessionId: string): Promise => { + deferredRetirementSessionIds.delete(sessionId); + const inFlight = createInFlight.get(sessionId); + createInFlight.delete(sessionId); + let runtime = runtimesBySessionId.get(sessionId); + if (!runtime && inFlight) { + runtime = await inFlight.promise.catch(() => undefined); + } + runtimesBySessionId.delete(sessionId); + idleTtlMsBySessionId.delete(sessionId); + if (!runtime) { + forgetSessionKeysForSessionId(sessionId); + return; + } + forgetSessionKeysForSessionId(sessionId); + await runtime.dispose(); + }; + return { async getOrCreate(params) { const idleTtlMs = resolveSessionMcpRuntimeIdleTtlMs(params.cfg); @@ -985,8 +1076,12 @@ function createSessionMcpRuntimeManager( existing.configFingerprint !== nextFingerprint ) { runtimesBySessionId.delete(params.sessionId); + deferredRetirementSessionIds.delete(params.sessionId); await existing.dispose(); } else { + // A new run now owns this runtime. Cancel an earlier one-shot retirement + // so an old app view cannot dispose the runtime out from under this run. + deferredRetirementSessionIds.delete(params.sessionId); existing.markUsed(); idleTtlMsBySessionId.set(params.sessionId, idleTtlMs); return existing; @@ -1005,6 +1100,7 @@ function createSessionMcpRuntimeManager( const staleRuntime = await inFlight.promise.catch(() => undefined); runtimesBySessionId.delete(params.sessionId); idleTtlMsBySessionId.delete(params.sessionId); + deferredRetirementSessionIds.delete(params.sessionId); await staleRuntime?.dispose(); } const created = Promise.resolve( @@ -1017,6 +1113,7 @@ function createSessionMcpRuntimeManager( configFingerprint: nextFingerprint, }), ).then((runtime) => { + deferredRetirementSessionIds.delete(params.sessionId); runtime.markUsed(); runtimesBySessionId.set(params.sessionId, runtime); idleTtlMsBySessionId.set(params.sessionId, idleTtlMs); @@ -1048,20 +1145,25 @@ function createSessionMcpRuntimeManager( return sessionId ? runtimesBySessionId.get(sessionId) : undefined; }, async disposeSession(sessionId) { - const inFlight = createInFlight.get(sessionId); - createInFlight.delete(sessionId); - let runtime = runtimesBySessionId.get(sessionId); - if (!runtime && inFlight) { - runtime = await inFlight.promise.catch(() => undefined); + await disposeManagedSession(sessionId); + }, + deferRetirement(sessionId) { + if (!runtimesBySessionId.has(sessionId)) { + return false; } - runtimesBySessionId.delete(sessionId); - idleTtlMsBySessionId.delete(sessionId); - if (!runtime) { - forgetSessionKeysForSessionId(sessionId); - return; + deferredRetirementSessionIds.add(sessionId); + return true; + }, + async completeDeferredRetirement(sessionId, runtime) { + if ( + !deferredRetirementSessionIds.has(sessionId) || + runtimesBySessionId.get(sessionId) !== runtime || + (runtime.activeLeases ?? 0) > 0 + ) { + return false; } - forgetSessionKeysForSessionId(sessionId); - await runtime.dispose(); + await disposeManagedSession(sessionId); + return true; }, async disposeAll() { clearIdleSweepTimer(); @@ -1071,6 +1173,7 @@ function createSessionMcpRuntimeManager( runtimesBySessionId.clear(); sessionIdBySessionKey.clear(); idleTtlMsBySessionId.clear(); + deferredRetirementSessionIds.clear(); const lateRuntimes = await Promise.all( inFlightRuntimes.map(async ({ promise }) => await promise.catch(() => undefined)), ); @@ -1123,12 +1226,18 @@ export async function disposeSessionMcpRuntime(sessionId: string): Promise export async function retireSessionMcpRuntime(params: { sessionId?: string | null; reason: string; + preserveActiveLeases?: boolean; onError?: (error: unknown, sessionId: string, reason: string) => void; }): Promise { const sessionId = normalizeOptionalString(params.sessionId); if (!sessionId) { return false; } + const runtime = peekSessionMcpRuntime({ sessionId }); + if (params.preserveActiveLeases === true && (runtime?.activeLeases ?? 0) > 0) { + getSessionMcpRuntimeManager().deferRetirement(sessionId); + return true; + } try { await disposeSessionMcpRuntime(sessionId); return true; @@ -1138,9 +1247,17 @@ export async function retireSessionMcpRuntime(params: { } } +/** Completes a one-shot retirement after its final run, view, or request lease releases. */ +export async function completeDeferredSessionMcpRuntimeRetirement( + runtime: SessionMcpRuntime, +): Promise { + return await getSessionMcpRuntimeManager().completeDeferredRetirement(runtime.sessionId, runtime); +} + export async function retireSessionMcpRuntimeForSessionKey(params: { sessionKey?: string | null; reason: string; + preserveActiveLeases?: boolean; onError?: (error: unknown, sessionId: string, reason: string) => void; }): Promise { const sessionKey = normalizeOptionalString(params.sessionKey); @@ -1151,6 +1268,7 @@ export async function retireSessionMcpRuntimeForSessionKey(params: { return await retireSessionMcpRuntime({ sessionId, reason: params.reason, + preserveActiveLeases: params.preserveActiveLeases, onError: params.onError, }); } @@ -1160,6 +1278,7 @@ export async function disposeAllSessionMcpRuntimes(): Promise { } export const testing = { + buildMcpClientCapabilities, createSessionMcpRuntimeManager, async resetSessionMcpRuntimeManager() { await disposeAllSessionMcpRuntimes(); diff --git a/src/agents/agent-bundle-mcp-tools.materialize.test.ts b/src/agents/agent-bundle-mcp-tools.materialize.test.ts index 47d85680f345..7e67c8f7c323 100644 --- a/src/agents/agent-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/agent-bundle-mcp-tools.materialize.test.ts @@ -1,7 +1,7 @@ /** Tests materializing MCP catalog tools into agent tool definitions and results. */ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { validateToolArguments } from "openclaw/plugin-sdk/llm"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPluginToolMeta } from "../plugins/tools.js"; import { buildBundleMcpToolsFromCatalog, @@ -12,6 +12,18 @@ import type { McpCatalogTool } from "./agent-bundle-mcp-types.js"; import type { McpToolCatalogDiagnostic } from "./agent-bundle-mcp-types.js"; import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; +const mcpAppMocks = vi.hoisted(() => ({ fetchMcpAppView: vi.fn() })); + +vi.mock("./mcp-ui-resource.js", () => ({ + fetchMcpAppView: mcpAppMocks.fetchMcpAppView, + buildMcpAppCanvasPayload: (view: { viewId: string; title: string }) => ({ + kind: "canvas", + view: { id: view.viewId, title: view.title }, + presentation: { target: "assistant_message", sandbox: "scripts" }, + mcpApp: { viewId: view.viewId }, + }), +})); + function expectTextContentBlock(block: unknown, text: string) { const content = block as { type?: string; text?: string } | undefined; expect(content?.type).toBe("text"); @@ -84,6 +96,75 @@ function makeToolRuntime( } describe("createBundleMcpToolRuntime", () => { + beforeEach(() => { + mcpAppMocks.fetchMcpAppView.mockReset(); + }); + + it("keeps app-only MCP tools out of the model tool catalog", async () => { + const runtime = await materializeBundleMcpToolsForRun({ + runtime: makeToolRuntime({ + tools: [ + { + serverName: "demo", + safeServerName: "demo", + toolName: "model_tool", + inputSchema: { type: "object" }, + fallbackDescription: "model", + uiVisibility: ["model"], + }, + { + serverName: "demo", + safeServerName: "demo", + toolName: "app_tool", + inputSchema: { type: "object" }, + fallbackDescription: "app", + uiVisibility: ["app"], + }, + { + serverName: "demo", + safeServerName: "demo", + toolName: "hidden_tool", + inputSchema: { type: "object" }, + fallbackDescription: "hidden", + uiVisibility: [], + }, + ], + }), + }); + + expect(runtime.tools.map((tool) => tool.name)).toEqual(["demo__model_tool"]); + }); + + it("attaches app previews without converting typed image results to text", async () => { + mcpAppMocks.fetchMcpAppView.mockResolvedValue({ + viewId: "cv_app", + title: "Demo UI", + }); + const tool: McpCatalogTool = { + serverName: "demo", + safeServerName: "demo", + toolName: "show", + inputSchema: { type: "object" }, + fallbackDescription: "show", + uiResourceUri: "ui://demo/app", + }; + const sessionRuntime = makeToolRuntime({ + tools: [tool], + serverName: "demo", + result: { + content: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }], + }, + }); + sessionRuntime.mcpAppsEnabled = true; + const materialized = await materializeBundleMcpToolsForRun({ runtime: sessionRuntime }); + + const result = await materialized.tools[0].execute("call-1", {}, undefined, undefined); + expect(result.content).toEqual([{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }]); + expect(result.details).toMatchObject({ + mcpAppPreview: { mcpApp: { viewId: "cv_app" } }, + }); + }); + it("materializes bundle MCP tools and executes them", async () => { const runtime = await materializeBundleMcpToolsForRun({ runtime: makeToolRuntime(), diff --git a/src/agents/agent-bundle-mcp-tools.ts b/src/agents/agent-bundle-mcp-tools.ts index 7dc0cdf0ef7a..7a1b278e8537 100644 --- a/src/agents/agent-bundle-mcp-tools.ts +++ b/src/agents/agent-bundle-mcp-tools.ts @@ -11,6 +11,7 @@ export type { export { testing, testing as __testing, + completeDeferredSessionMcpRuntimeRetirement, createSessionMcpRuntime, disposeAllSessionMcpRuntimes, disposeSessionMcpRuntime, diff --git a/src/agents/agent-bundle-mcp-types.ts b/src/agents/agent-bundle-mcp-types.ts index b8f95fb45102..c4e4516ad3eb 100644 --- a/src/agents/agent-bundle-mcp-types.ts +++ b/src/agents/agent-bundle-mcp-types.ts @@ -1,5 +1,9 @@ /** Shared bundle MCP catalog, runtime, and manager types. */ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { + CallToolResult, + ListResourceTemplatesResult, + ListToolsResult, +} from "@modelcontextprotocol/sdk/types.js"; import type { TSchema } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -44,6 +48,8 @@ export type McpCatalogTool = { description?: string; inputSchema: TSchema; fallbackDescription: string; + uiResourceUri?: string; + uiVisibility?: Array<"app" | "model">; }; /** Complete tool catalog for a session-scoped MCP runtime. */ @@ -69,6 +75,7 @@ export type SessionMcpRuntime = { workspaceDir: string; agentDir?: string; configFingerprint: string; + mcpAppsEnabled?: boolean; createdAt: number; lastUsedAt: number; activeLeases?: number; @@ -79,8 +86,13 @@ export type SessionMcpRuntime = { peekCatalog: () => McpToolCatalog | null; markUsed: () => void; callTool: (serverName: string, toolName: string, input: unknown) => Promise; + listTools?: (serverName: string, params?: { cursor?: string }) => Promise; listResources?: (serverName: string) => Promise; readResource?: (serverName: string, uri: string) => Promise; + listResourceTemplates?: ( + serverName: string, + params?: { cursor?: string }, + ) => Promise; listPrompts?: (serverName: string) => Promise; getPrompt?: (serverName: string, name: string, args?: Record) => Promise; dispose: () => Promise; @@ -103,6 +115,8 @@ export type SessionMcpRuntimeManager = { sessionKey?: string; }) => SessionMcpRuntime | undefined; disposeSession: (sessionId: string) => Promise; + deferRetirement: (sessionId: string) => boolean; + completeDeferredRetirement: (sessionId: string, runtime: SessionMcpRuntime) => Promise; disposeAll: () => Promise; sweepIdleRuntimes: () => Promise; listSessionIds: () => string[]; diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 6730c9f3a4a9..e42a5db492f1 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -5000,12 +5000,16 @@ async function runEmbeddedAgentInternal( const retiredBySessionKey = await retireSessionMcpRuntimeForSessionKey({ sessionKey: params.sessionKey, reason: "embedded-run-end", + // MCP App views hold bounded leases so their bridge can remain + // usable after a one-shot gateway run returns. + preserveActiveLeases: true, onError, }); if (!retiredBySessionKey) { await retireSessionMcpRuntime({ sessionId: params.sessionId, reason: "embedded-run-end", + preserveActiveLeases: true, onError, }); } diff --git a/src/agents/mcp-app-sandbox.ts b/src/agents/mcp-app-sandbox.ts new file mode 100644 index 000000000000..eed678d26f1d --- /dev/null +++ b/src/agents/mcp-app-sandbox.ts @@ -0,0 +1,172 @@ +export type McpAppCsp = { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + baseUriDomains?: string[]; +}; + +export const MCP_APP_SANDBOX_PATH = "/mcp-app-sandbox"; +export const MCP_APP_SANDBOX_PORT_OFFSET = 1; +const MCP_APP_SANDBOX_CSP_QUERY = "csp"; +const MCP_APP_SANDBOX_CSP_MAX_JSON_BYTES = 5 * 1024; +const MCP_APP_SANDBOX_CSP_MAX_HEADER_BYTES = 6 * 1024; +const MCP_APP_SANDBOX_CSP_MAX_ENCODED_BYTES = + Math.ceil(MCP_APP_SANDBOX_CSP_MAX_JSON_BYTES / 3) * 4 + 4; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function normalizeDomains( + value: unknown, + options?: { allowWebSocket?: boolean }, +): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const protocols = options?.allowWebSocket ? "(?:https?|wss?)" : "https?"; + const pattern = new RegExp(`^${protocols}:\\/\\/(?:\\*\\.)?[A-Za-z0-9.-]+(?::[0-9]+)?$`); + const entries = value.filter( + (entry): entry is string => + typeof entry === "string" && entry.length <= 2048 && pattern.test(entry), + ); + return entries.length > 0 ? entries : undefined; +} + +export function normalizeMcpAppCsp(value: unknown): McpAppCsp | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const csp: McpAppCsp = { + connectDomains: normalizeDomains(record.connectDomains, { allowWebSocket: true }), + resourceDomains: normalizeDomains(record.resourceDomains), + frameDomains: normalizeDomains(record.frameDomains), + baseUriDomains: normalizeDomains(record.baseUriDomains), + }; + if (!Object.values(csp).some(Boolean)) { + return undefined; + } + const jsonBytes = Buffer.byteLength(JSON.stringify(csp), "utf8"); + const headerBytes = Buffer.byteLength(buildMcpAppContentSecurityPolicy(csp), "utf8"); + if ( + jsonBytes > MCP_APP_SANDBOX_CSP_MAX_JSON_BYTES || + headerBytes > MCP_APP_SANDBOX_CSP_MAX_HEADER_BYTES + ) { + throw new Error("MCP App CSP metadata exceeds safe HTTP limits"); + } + return csp; +} + +function encodeCsp(csp?: McpAppCsp): string | undefined { + const normalized = normalizeMcpAppCsp(csp); + if (!normalized) { + return undefined; + } + return Buffer.from(JSON.stringify(normalized), "utf8").toString("base64url"); +} + +export function buildMcpAppSandboxPath(csp?: McpAppCsp): string { + const encoded = encodeCsp(csp); + return encoded + ? `${MCP_APP_SANDBOX_PATH}?${MCP_APP_SANDBOX_CSP_QUERY}=${encoded}` + : MCP_APP_SANDBOX_PATH; +} + +export function resolveMcpAppSandboxPort(gatewayPort: number, configuredPort?: number): number { + const sandboxPort = configuredPort ?? gatewayPort + MCP_APP_SANDBOX_PORT_OFFSET; + if ( + !Number.isInteger(gatewayPort) || + gatewayPort < 1 || + gatewayPort > 65535 || + !Number.isInteger(sandboxPort) || + sandboxPort < 1 || + sandboxPort > 65535 || + sandboxPort === gatewayPort + ) { + throw new Error("MCP Apps require distinct valid Gateway and sandbox ports"); + } + return sandboxPort; +} + +export function decodeMcpAppSandboxCsp(value: string | null): McpAppCsp | undefined { + if (!value) { + return undefined; + } + if (value.length > MCP_APP_SANDBOX_CSP_MAX_ENCODED_BYTES) { + throw new Error("MCP App CSP metadata is too large"); + } + const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown; + return normalizeMcpAppCsp(decoded); +} + +/** Trusted outer document. The untrusted app HTML is written only into its inner iframe. */ +export function buildMcpAppSandboxProxyHtml(): string { + return ` + + +MCP App sandbox + + + +`; +} + +/** HTTP response policy for the isolated proxy and its inner about:blank app. */ +export function buildMcpAppContentSecurityPolicy(csp?: McpAppCsp): string { + const resources = csp?.resourceDomains ?? []; + const connections = csp?.connectDomains ?? []; + const frames = csp?.frameDomains ?? []; + const bases = csp?.baseUriDomains ?? []; + const sources = (values: string[]) => (values.length > 0 ? values.join(" ") : "'none'"); + const directives = [ + "default-src 'none'", + `script-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(), + `style-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(), + `img-src 'self' data: ${resources.join(" ")}`.trim(), + `media-src 'self' data: ${resources.join(" ")}`.trim(), + `connect-src ${sources(connections)}`, + `frame-src ${sources(frames)}`, + `base-uri ${bases.length > 0 ? bases.join(" ") : "'self'"}`, + "object-src 'none'", + "form-action 'none'", + ]; + if (csp) { + directives.splice(5, 0, `font-src 'self' ${resources.join(" ")}`.trim()); + } + return directives.join("; "); +} diff --git a/src/agents/mcp-ui-resource.test.ts b/src/agents/mcp-ui-resource.test.ts new file mode 100644 index 000000000000..b32000e10387 --- /dev/null +++ b/src/agents/mcp-ui-resource.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; +import { + buildMcpAppContentSecurityPolicy, + buildMcpAppSandboxPath, + buildMcpAppSandboxProxyHtml, + decodeMcpAppSandboxCsp, + resolveMcpAppSandboxPort, +} from "./mcp-app-sandbox.js"; +import { + __testing, + acquireMcpAppViewRequest, + fetchMcpAppView, + getMcpAppViewLease, + MCP_APP_RESOURCE_MAX_BYTES, + MCP_APP_RESOURCE_MIME_TYPE, +} from "./mcp-ui-resource.js"; + +function runtime(readResource: SessionMcpRuntime["readResource"]): SessionMcpRuntime { + return { + sessionId: "session-1", + workspaceDir: "/tmp", + configFingerprint: "fingerprint", + createdAt: 0, + lastUsedAt: 0, + mcpAppsEnabled: true, + activeLeases: 0, + acquireLease: vi.fn(() => vi.fn()), + markUsed: () => {}, + getCatalog: async () => ({ version: 1, generatedAt: 0, servers: {}, tools: [] }), + peekCatalog: () => null, + callTool: vi.fn(), + readResource, + dispose: async () => {}, + }; +} + +describe("MCP App UI resources", () => { + beforeEach(() => { + __testing.clearViewStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("leases HTML and tool data only in memory", async () => { + const sessionRuntime = runtime(async () => ({ + contents: [ + { + uri: "ui://demo/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "demo", + _meta: { + ui: { + csp: { connectDomains: ["https://api.example.com"] }, + permissions: { geolocation: {} }, + }, + }, + }, + ], + })); + const result = await fetchMcpAppView({ + runtime: sessionRuntime, + serverName: "demo", + toolName: "show", + uiResourceUri: "ui://demo/app", + toolInput: { city: "Paris" }, + toolResult: { content: [{ type: "text", text: "ok" }] }, + }); + + expect(result?.viewId).toMatch(/^mcp-app-/u); + expect(getMcpAppViewLease(result?.viewId ?? "", sessionRuntime)).toMatchObject({ + html: "demo", + toolInput: { city: "Paris" }, + permissions: { geolocation: {} }, + }); + expect( + getMcpAppViewLease( + result?.viewId ?? "", + runtime(async () => ({ contents: [] })), + ), + ).toBeUndefined(); + }); + + it("rejects oversized and incorrectly typed resources", async () => { + for (const content of [ + { + uri: "ui://demo/app", + mimeType: "text/html", + text: "", + }, + { + uri: "ui://demo/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "x".repeat(MCP_APP_RESOURCE_MAX_BYTES + 1), + }, + ]) { + const result = await fetchMcpAppView({ + runtime: runtime(async () => ({ contents: [content] })), + serverName: "demo", + toolName: "show", + uiResourceUri: "ui://demo/app", + toolInput: {}, + toolResult: { content: [] }, + }); + expect(result).toBeUndefined(); + } + }); + + it("bounds concurrent app bridge requests", () => { + const view = { + requestWindowStartedAtMs: 0, + requestCount: 0, + toolCallCount: 0, + activeRequests: 0, + } as Parameters[0]; + const releases = Array.from({ length: 4 }, () => acquireMcpAppViewRequest(view, "read", 1)); + expect(() => acquireMcpAppViewRequest(view, "read", 1)).toThrow("concurrency limit"); + releases[0]?.(); + const release = acquireMcpAppViewRequest(view, "read", 1); + release(); + releases.slice(1).forEach((entry) => entry()); + }); + + it("injects a restrictive CSP and drops invalid metadata origins", async () => { + const sessionRuntime = runtime(async () => ({ + contents: [ + { + uri: "ui://demo/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "", + _meta: { + ui: { + csp: { + connectDomains: ["https://api.example.com", "javascript:alert(1)"], + resourceDomains: ["https://cdn.example.com"], + }, + }, + }, + }, + ], + })); + const result = await fetchMcpAppView({ + runtime: sessionRuntime, + serverName: "demo", + toolName: "show", + uiResourceUri: "ui://demo/app", + toolInput: {}, + toolResult: { content: [] }, + }); + const view = getMcpAppViewLease(result?.viewId ?? "", sessionRuntime); + const policy = buildMcpAppContentSecurityPolicy(view?.csp); + + expect(policy).toContain("connect-src https://api.example.com"); + expect(policy).toContain("script-src 'self' 'unsafe-inline' https://cdn.example.com"); + expect(policy).toContain("font-src 'self' https://cdn.example.com"); + expect(policy).not.toContain("worker-src"); + expect(policy).not.toContain("script-src 'self' 'unsafe-inline' blob:"); + expect(policy).toContain("base-uri 'self'"); + expect(policy).not.toContain("javascript:alert"); + expect(view?.html.startsWith("")).toBe(true); + expect(policy).not.toContain("frame-ancestors"); + const sandboxPath = buildMcpAppSandboxPath(view?.csp); + const encodedCsp = new URL(sandboxPath, "https://gateway.example").searchParams.get("csp"); + expect(decodeMcpAppSandboxCsp(encodedCsp)).toStrictEqual(view?.csp); + const proxyHtml = buildMcpAppSandboxProxyHtml(); + expect(proxyHtml.startsWith('\n { + vi.useFakeTimers(); + const sessionRuntime = runtime(async () => ({ + contents: [ + { + uri: "ui://demo/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "secret", + }, + ], + })); + const result = await fetchMcpAppView({ + runtime: sessionRuntime, + serverName: "demo", + toolName: "show", + uiResourceUri: "ui://demo/app", + toolInput: { token: "secret" }, + toolResult: { content: [] }, + }); + expect(getMcpAppViewLease(result?.viewId ?? "", sessionRuntime)).toBeDefined(); + + await vi.advanceTimersByTimeAsync(10 * 60_000); + + expect(getMcpAppViewLease(result?.viewId ?? "", sessionRuntime)).toBeUndefined(); + expect(sessionRuntime.acquireLease).toHaveBeenCalledOnce(); + const release = vi.mocked(sessionRuntime.acquireLease!).mock.results[0]?.value; + expect(release).toHaveBeenCalledOnce(); + }); + + it("rejects CSP metadata that cannot fit safe HTTP request and response limits", () => { + const shortDomains = Array.from( + { length: 65 }, + (_, index) => `https://cdn-${index}.example.com`, + ); + const path = buildMcpAppSandboxPath({ connectDomains: shortDomains }); + const encoded = new URL(path, "https://gateway.example").searchParams.get("csp"); + expect(decodeMcpAppSandboxCsp(encoded)?.connectDomains).toStrictEqual(shortDomains); + + const domains = Array.from( + { length: 64 }, + (_, index) => `https://${"a".repeat(120)}-${index}.example.com`, + ); + expect(() => + buildMcpAppSandboxPath({ + connectDomains: domains, + resourceDomains: domains, + frameDomains: domains, + baseUriDomains: domains, + }), + ).toThrow("MCP App CSP metadata exceeds safe HTTP limits"); + }); + + it("uses the stable restrictive CSP when metadata is omitted", () => { + const policy = buildMcpAppContentSecurityPolicy(); + expect(policy).toContain("default-src 'none'"); + expect(policy).toContain("script-src 'self' 'unsafe-inline'"); + expect(policy).toContain("style-src 'self' 'unsafe-inline'"); + expect(policy).toContain("img-src 'self' data:"); + expect(policy).toContain("media-src 'self' data:"); + expect(policy).toContain("connect-src 'none'"); + expect(policy).not.toMatch(/\b(?:blob|font|worker)-src\b/u); + expect(policy).not.toContain("blob:"); + }); + + it("derives a distinct listener port without wrapping", () => { + expect(resolveMcpAppSandboxPort(18789)).toBe(18790); + expect(resolveMcpAppSandboxPort(18789, 29000)).toBe(29000); + expect(() => resolveMcpAppSandboxPort(65535)).toThrow( + "MCP Apps require distinct valid Gateway and sandbox ports", + ); + expect(() => resolveMcpAppSandboxPort(18789, 18789)).toThrow( + "MCP Apps require distinct valid Gateway and sandbox ports", + ); + }); + + it("keeps all 32 valid leases during lookup-only pruning", async () => { + const sessionRuntime = runtime(async () => ({ + contents: [ + { + uri: "ui://demo/app", + mimeType: MCP_APP_RESOURCE_MIME_TYPE, + text: "demo", + }, + ], + })); + const viewIds: string[] = []; + for (let index = 0; index < 32; index += 1) { + const result = await fetchMcpAppView({ + runtime: sessionRuntime, + serverName: "demo", + toolName: "show", + uiResourceUri: "ui://demo/app", + toolInput: { index }, + toolResult: { content: [] }, + }); + if (result) { + viewIds.push(result.viewId); + } + } + + expect(getMcpAppViewLease(viewIds[0] ?? "", sessionRuntime)).toBeDefined(); + expect(getMcpAppViewLease(viewIds[31] ?? "", sessionRuntime)).toBeDefined(); + }); +}); diff --git a/src/agents/mcp-ui-resource.ts b/src/agents/mcp-ui-resource.ts new file mode 100644 index 000000000000..3f864697b293 --- /dev/null +++ b/src/agents/mcp-ui-resource.ts @@ -0,0 +1,299 @@ +import { randomUUID } from "node:crypto"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { logWarn } from "../logger.js"; +import { completeDeferredSessionMcpRuntimeRetirement } from "./agent-bundle-mcp-runtime.js"; +import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js"; +import { type McpAppCsp, normalizeMcpAppCsp } from "./mcp-app-sandbox.js"; + +export const MCP_APP_RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; +export const MCP_APP_RESOURCE_MAX_BYTES = 2 * 1024 * 1024; +const MCP_APP_VIEW_TTL_MS = 10 * 60_000; +const MCP_APP_VIEW_MAX_ENTRIES = 32; +const MCP_APP_VIEW_MAX_BYTES = 6 * 1024 * 1024; +const MCP_APP_VIEW_STORE_MAX_BYTES = 64 * 1024 * 1024; +const MCP_APP_VIEW_STORE_KEY = Symbol.for("openclaw.mcpAppViewStore"); + +type McpAppPermissions = Partial< + Record<"camera" | "clipboardWrite" | "geolocation" | "microphone", Record> +>; + +export type McpAppViewLease = { + viewId: string; + runtime: SessionMcpRuntime; + sessionId: string; + serverName: string; + toolName: string; + uiResourceUri: string; + html: string; + csp?: McpAppCsp; + permissions?: McpAppPermissions; + toolInput: unknown; + toolResult: CallToolResult; + expiresAtMs: number; + requestWindowStartedAtMs: number; + requestCount: number; + toolCallCount: number; + activeRequests: number; + byteSize: number; + expiryTimer?: ReturnType; + releaseRuntimeLease?: () => void; +}; + +type McpAppViewStore = Map; + +function getViewStore(): McpAppViewStore { + const globalStore = globalThis as Record; + const existing = globalStore[MCP_APP_VIEW_STORE_KEY] as McpAppViewStore | undefined; + if (existing) { + return existing; + } + const store = new Map(); + globalStore[MCP_APP_VIEW_STORE_KEY] = store; + return store; +} + +function deleteView(viewId: string, expected?: McpAppViewLease): void { + const store = getViewStore(); + const view = store.get(viewId); + if (!view || (expected && view !== expected)) { + return; + } + clearTimeout(view.expiryTimer); + view.releaseRuntimeLease?.(); + store.delete(viewId); + void completeDeferredSessionMcpRuntimeRetirement(view.runtime).catch((error: unknown) => { + logWarn(`mcp-app: deferred runtime cleanup failed: ${formatErrorMessage(error)}`); + }); +} + +function pruneViewStore( + additionalBytes = 0, + options?: { reserveEntry?: boolean; nowMs?: number }, +): void { + const store = getViewStore(); + const nowMs = options?.nowMs ?? Date.now(); + for (const [viewId, view] of store) { + if (view.expiresAtMs <= nowMs) { + deleteView(viewId, view); + } + } + let totalBytes = Array.from(store.values()).reduce((sum, view) => sum + (view.byteSize ?? 0), 0); + while ( + store.size + (options?.reserveEntry ? 1 : 0) > MCP_APP_VIEW_MAX_ENTRIES || + totalBytes + additionalBytes > MCP_APP_VIEW_STORE_MAX_BYTES + ) { + const oldest = store.keys().next().value; + if (oldest === undefined) { + return; + } + const evicted = store.get(oldest); + totalBytes -= evicted?.byteSize ?? 0; + if (evicted) { + deleteView(oldest, evicted); + } + } +} + +function measureViewBytes(html: string, toolInput: unknown, toolResult: CallToolResult): number { + const toolData = JSON.stringify({ toolInput, toolResult }); + const byteSize = Buffer.byteLength(html, "utf8") + Buffer.byteLength(toolData, "utf8"); + if (byteSize > MCP_APP_VIEW_MAX_BYTES) { + throw new Error(`MCP App view data exceeds ${MCP_APP_VIEW_MAX_BYTES} bytes`); + } + return byteSize; +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function normalizePermissions(value: unknown): McpAppPermissions | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const permissions: McpAppPermissions = {}; + for (const key of ["camera", "clipboardWrite", "geolocation", "microphone"] as const) { + if (asRecord(record[key])) { + permissions[key] = {}; + } + } + return Object.keys(permissions).length > 0 ? permissions : undefined; +} + +function decodeResourceHtml(content: Record): string { + if (typeof content.text === "string") { + if (Buffer.byteLength(content.text, "utf8") > MCP_APP_RESOURCE_MAX_BYTES) { + throw new Error(`MCP App resource exceeds ${MCP_APP_RESOURCE_MAX_BYTES} bytes`); + } + return content.text; + } + if (typeof content.blob !== "string") { + throw new Error("MCP App resource must provide text or base64 blob content"); + } + const maxEncodedBytes = Math.ceil(MCP_APP_RESOURCE_MAX_BYTES / 3) * 4 + 4; + if (content.blob.length > maxEncodedBytes) { + throw new Error(`MCP App resource exceeds ${MCP_APP_RESOURCE_MAX_BYTES} bytes`); + } + const decoded = Buffer.from(content.blob, "base64"); + if (decoded.byteLength > MCP_APP_RESOURCE_MAX_BYTES) { + throw new Error(`MCP App resource exceeds ${MCP_APP_RESOURCE_MAX_BYTES} bytes`); + } + return decoded.toString("utf8"); +} + +async function resolveListingUiMeta( + runtime: SessionMcpRuntime, + serverName: string, + uri: string, +): Promise | undefined> { + const listed = await runtime.listResources?.(serverName); + const resources = Array.isArray(listed) + ? listed + : Array.isArray(asRecord(listed)?.resources) + ? (asRecord(listed)?.resources as unknown[]) + : []; + const resource = resources.map(asRecord).find((entry) => entry?.uri === uri); + const { _meta: metadata } = resource ?? {}; + return asRecord(asRecord(metadata)?.ui); +} + +export async function fetchMcpAppView(params: { + runtime: SessionMcpRuntime; + serverName: string; + toolName: string; + uiResourceUri: string; + toolInput: unknown; + toolResult: CallToolResult; +}): Promise<{ viewId: string; title: string } | undefined> { + let releaseRuntimeLease: (() => void) | undefined; + try { + if (!params.runtime.readResource || !params.uiResourceUri.startsWith("ui://")) { + return undefined; + } + const result = asRecord( + await params.runtime.readResource(params.serverName, params.uiResourceUri), + ); + const contents = Array.isArray(result?.contents) ? result.contents : []; + if (contents.length !== 1) { + throw new Error(`expected one MCP App resource, received ${contents.length}`); + } + const content = asRecord(contents[0]); + if (!content || content.mimeType !== MCP_APP_RESOURCE_MIME_TYPE) { + throw new Error(`resource must use ${MCP_APP_RESOURCE_MIME_TYPE}`); + } + const html = decodeResourceHtml(content); + const byteSize = measureViewBytes(html, params.toolInput, params.toolResult); + const { _meta: metadata, meta: deprecatedMetadata } = content; + const contentUiMeta = asRecord(asRecord(metadata ?? deprecatedMetadata)?.ui); + const listingUiMeta = contentUiMeta + ? undefined + : await resolveListingUiMeta(params.runtime, params.serverName, params.uiResourceUri); + const uiMeta = contentUiMeta ?? listingUiMeta; + const csp = normalizeMcpAppCsp(uiMeta?.csp); + const permissions = normalizePermissions(uiMeta?.permissions); + const title = `${params.toolName} UI`; + const viewId = `mcp-app-${randomUUID()}`; + releaseRuntimeLease = params.runtime.acquireLease?.(); + pruneViewStore(byteSize, { reserveEntry: true }); + const view: McpAppViewLease = { + viewId, + runtime: params.runtime, + sessionId: params.runtime.sessionId, + serverName: params.serverName, + toolName: params.toolName, + uiResourceUri: params.uiResourceUri, + html, + ...(csp ? { csp } : {}), + ...(permissions ? { permissions } : {}), + toolInput: params.toolInput, + toolResult: params.toolResult, + expiresAtMs: Date.now() + MCP_APP_VIEW_TTL_MS, + requestWindowStartedAtMs: Date.now(), + requestCount: 0, + toolCallCount: 0, + activeRequests: 0, + byteSize, + ...(releaseRuntimeLease ? { releaseRuntimeLease } : {}), + }; + releaseRuntimeLease = undefined; + view.expiryTimer = setTimeout(() => { + deleteView(view.viewId, view); + }, MCP_APP_VIEW_TTL_MS); + view.expiryTimer.unref?.(); + getViewStore().set(viewId, view); + return { viewId, title }; + } catch (error) { + releaseRuntimeLease?.(); + logWarn( + `mcp-app: failed to prepare ${params.uiResourceUri} from "${params.serverName}": ${formatErrorMessage(error)}`, + ); + return undefined; + } +} + +export function getMcpAppViewLease( + viewId: string, + runtime: SessionMcpRuntime, +): McpAppViewLease | undefined { + pruneViewStore(); + const view = getViewStore().get(viewId); + return view?.runtime === runtime ? view : undefined; +} + +export function acquireMcpAppViewRequest( + view: McpAppViewLease, + kind: "read" | "tool", + nowMs = Date.now(), +): () => void { + if (nowMs - view.requestWindowStartedAtMs >= 60_000) { + view.requestWindowStartedAtMs = nowMs; + view.requestCount = 0; + view.toolCallCount = 0; + } + if (view.activeRequests >= 4) { + throw new Error("MCP App request concurrency limit reached"); + } + if (view.requestCount >= 120 || (kind === "tool" && view.toolCallCount >= 30)) { + throw new Error("MCP App request rate limit reached"); + } + view.requestCount += 1; + if (kind === "tool") { + view.toolCallCount += 1; + } + view.activeRequests += 1; + let released = false; + return () => { + if (!released) { + released = true; + view.activeRequests = Math.max(0, view.activeRequests - 1); + } + }; +} + +export function buildMcpAppCanvasPayload(view: { viewId: string; title: string }) { + return { + kind: "canvas", + view: { id: view.viewId, title: view.title }, + presentation: { + target: "assistant_message", + title: view.title, + preferred_height: 600, + sandbox: "scripts", + }, + mcpApp: { viewId: view.viewId }, + }; +} + +const testing = { + clearViewStore() { + for (const [viewId, view] of getViewStore()) { + deleteView(viewId, view); + } + }, +}; + +export { testing as __testing }; diff --git a/src/chat/canvas-render.test.ts b/src/chat/canvas-render.test.ts index c7945195797f..883a2f170777 100644 --- a/src/chat/canvas-render.test.ts +++ b/src/chat/canvas-render.test.ts @@ -1,6 +1,43 @@ // Canvas-render tests cover [embed] shortcode extraction and text stripping. import { describe, expect, it } from "vitest"; -import { extractCanvasShortcodes } from "./canvas-render.ts"; +import { + extractCanvasFromDetails, + extractCanvasFromText, + extractCanvasShortcodes, +} from "./canvas-render.ts"; + +describe("extractCanvasFromText", () => { + it("extracts safe MCP App preview metadata from tool details", () => { + expect( + extractCanvasFromDetails({ + mcpAppPreview: { + kind: "canvas", + view: { id: "cv_app" }, + presentation: { target: "assistant_message", sandbox: "scripts" }, + mcpApp: { viewId: "cv_app" }, + }, + }), + ).toMatchObject({ viewId: "cv_app", mcpApp: { viewId: "cv_app" } }); + }); + + it("keeps MCP App previews opaque while preserving model-visible results", () => { + const preview = extractCanvasFromText( + JSON.stringify({ + kind: "canvas", + view: { id: "cv_app" }, + presentation: { target: "assistant_message", sandbox: "scripts" }, + mcpApp: { viewId: "cv_app" }, + result: [{ type: "text", text: "model-visible result" }], + }), + ); + + expect(preview).toMatchObject({ + viewId: "cv_app", + sandbox: "scripts", + mcpApp: { viewId: "cv_app" }, + }); + }); +}); describe("extractCanvasShortcodes", () => { it("does not let a self-closing embed start a greedy block match", () => { diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index 5b785dd9744a..a9a269c705dd 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -20,6 +20,7 @@ type CanvasPreview = { className?: string; style?: string; sandbox?: CanvasSandbox; + mcpApp?: { viewId: string }; }; function getRecordStringField( @@ -73,6 +74,8 @@ function coerceCanvasPreview( const presentation = getNestedRecord(record, "presentation"); const view = getNestedRecord(record, "view"); const source = getNestedRecord(record, "source"); + const mcpAppRecord = getNestedRecord(record, "mcpApp"); + const mcpAppViewId = getRecordStringField(mcpAppRecord, "viewId"); const requestedSurface = getRecordStringField(presentation, "target") ?? getRecordStringField(record, "target"); const surface = requestedSurface ? normalizeSurface(requestedSurface) : "assistant_message"; @@ -93,6 +96,18 @@ function coerceCanvasPreview( const sandbox = normalizeSandbox(getRecordStringField(presentation, "sandbox")); const viewUrl = getRecordStringField(view, "url") ?? getRecordStringField(view, "entryUrl"); const viewId = getRecordStringField(view, "id") ?? getRecordStringField(view, "docId"); + if (mcpAppViewId && viewId === mcpAppViewId) { + return { + kind: "canvas", + surface, + render: "url", + viewId, + ...(title ? { title } : {}), + ...(preferredHeight ? { preferredHeight } : {}), + ...(sandbox ? { sandbox } : {}), + mcpApp: { viewId: mcpAppViewId }, + }; + } if (viewUrl) { return { kind: "canvas", @@ -105,6 +120,7 @@ function coerceCanvasPreview( ...(className ? { className } : {}), ...(style ? { style } : {}), ...(sandbox ? { sandbox } : {}), + ...(mcpAppViewId ? { mcpApp: { viewId: mcpAppViewId } } : {}), }; } const sourceType = getRecordStringField(source, "type")?.trim().toLowerCase(); @@ -123,11 +139,18 @@ function coerceCanvasPreview( ...(className ? { className } : {}), ...(style ? { style } : {}), ...(sandbox ? { sandbox } : {}), + ...(mcpAppViewId ? { mcpApp: { viewId: mcpAppViewId } } : {}), }; } return undefined; } +/** Extracts an MCP App Canvas preview from sanitized tool-result details. */ +export function extractCanvasFromDetails(value: unknown): CanvasPreview | undefined { + const details = asOptionalRecord(value); + return coerceCanvasPreview(asOptionalRecord(details?.mcpAppPreview)); +} + function parseCanvasAttributes(raw: string): Record { const attrs: Record = {}; const re = /([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 7fc7edacbcba..cf5136cbdbcc 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -721,6 +721,39 @@ describe("plugins.entries.*.hooks", () => { }); }); +describe("mcp.apps.enabled", () => { + it.each([true, false])("accepts %s", (enabled) => { + expect(OpenClawSchema.safeParse({ mcp: { apps: { enabled } } }).success).toBe(true); + }); + + it("rejects non-boolean values", () => { + expect(OpenClawSchema.safeParse({ mcp: { apps: { enabled: "yes" } } }).success).toBe(false); + }); + + it("accepts only a bare HTTP(S) sandbox origin", () => { + expect( + OpenClawSchema.safeParse({ + mcp: { + apps: { + enabled: true, + sandboxOrigin: "https://mcp-apps.example.com", + sandboxPort: 29000, + }, + }, + }).success, + ).toBe(true); + expect(OpenClawSchema.safeParse({ mcp: { apps: { sandboxPort: 65536 } } }).success).toBe(false); + for (const sandboxOrigin of [ + "https://mcp-apps.example.com/path", + "https://mcp-apps.example.com?query=1", + "https://user:pass@mcp-apps.example.com", + "data:text/html,hello", + ]) { + expect(OpenClawSchema.safeParse({ mcp: { apps: { sandboxOrigin } } }).success).toBe(false); + } + }); +}); + describe("plugins.entries.*.subagent", () => { it("accepts trusted subagent override settings", () => { const result = OpenClawSchema.safeParse({ diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 901680537797..f13d97de11bb 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -623,6 +623,14 @@ export const FIELD_HELP: Record = { "Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.", + "mcp.apps": + "MCP Apps UI support. When enabled, configured MCP servers may provide interactive HTML views for their tool results.", + "mcp.apps.enabled": + "Opt-in MCP Apps rendering and app-to-server bridge. Keep disabled unless you trust the configured MCP servers that provide app UI resources.", + "mcp.apps.sandboxOrigin": + "Optional dedicated public HTTP(S) origin for MCP Apps. Use this behind a reverse proxy or TLS terminator and proxy it only to the configured MCP Apps sandbox port. It must differ from the Control UI origin and must not serve authenticated content.", + "mcp.apps.sandboxPort": + "Dedicated MCP Apps sandbox listener port. Defaults to the Gateway port plus one. Set an unused port when another local service or Gateway profile already owns that port.", "gateway.push": "Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.", "gateway.push.apns": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index a0a4045dc843..ebec78d2e7cc 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -383,6 +383,10 @@ export const FIELD_LABELS: Record = { "Dangerously Allow Host-Header Origin Fallback", "gateway.controlUi.allowInsecureAuth": "Insecure Control UI Auth Toggle", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", + "mcp.apps": "MCP Apps", + "mcp.apps.enabled": "MCP Apps Enabled", + "mcp.apps.sandboxOrigin": "MCP Apps Sandbox Origin", + "mcp.apps.sandboxPort": "MCP Apps Sandbox Port", "gateway.push": "Gateway Push Delivery", "gateway.push.apns": "Gateway APNs Delivery", "gateway.push.apns.relay": "Gateway APNs Relay", diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index 61073f14bd1c..0e010081bcb9 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -60,6 +60,9 @@ const TAG_OVERRIDES: Record = { "gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"], "gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"], "gateway.nodes.pairing.sshVerify": ["security", "access", "network", "advanced"], + "mcp.apps.enabled": ["security", "access", "advanced"], + "mcp.apps.sandboxOrigin": ["security", "network", "advanced"], + "mcp.apps.sandboxPort": ["network", "advanced"], "gateway.nodes.pluginTools.enabled": ["tools", "security", "access", "network", "advanced"], "gateway.nodes.skills.enabled": ["tools", "security", "access", "network", "advanced"], "nodeHost.mcp.servers": ["tools", "network", "advanced"], diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts index e378b626e1a4..3281e1008c4a 100644 --- a/src/config/types.mcp.ts +++ b/src/config/types.mcp.ts @@ -82,6 +82,14 @@ export type McpServerConfig = { export type McpConfig = { /** Named MCP server definitions managed by OpenClaw. */ servers?: Record; + /** Opt-in MCP Apps rendering and app-to-server bridge. */ + apps?: { + enabled?: boolean; + /** Dedicated public origin that proxies to the sandbox listener. */ + sandboxOrigin?: string; + /** Dedicated listener port. Defaults to the Gateway port plus one. */ + sandboxPort?: number; + }; /** * Idle TTL for session-scoped bundled MCP runtimes, in milliseconds. * diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 23335b27eb57..d57818344508 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -470,6 +470,30 @@ export const McpServerSchema = z const McpConfigSchema = z .object({ servers: z.record(z.string(), McpServerSchema).optional(), + apps: z + .object({ + enabled: z.boolean().optional(), + sandboxOrigin: z + .string() + .url() + .refine((value) => { + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.origin === value.replace(/\/$/u, "") && + !url.username && + !url.password + ); + } catch { + return false; + } + }, "sandboxOrigin must be an HTTP(S) origin without a path, query, or credentials") + .optional(), + sandboxPort: z.number().int().min(1).max(65535).optional(), + }) + .strict() + .optional(), sessionIdleTtlMs: z.number().finite().min(0).optional(), }) .strict() diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts index 3dea7e9de512..96f0ac891ce1 100644 --- a/src/gateway/chat-display-projection.ts +++ b/src/gateway/chat-display-projection.ts @@ -13,7 +13,7 @@ import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../agents/internal-runtime import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js"; import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; -import { extractCanvasFromText } from "../chat/canvas-render.js"; +import { extractCanvasFromDetails, extractCanvasFromText } from "../chat/canvas-render.js"; import { INTER_SESSION_PROMPT_PREFIX_BASE, normalizeInputProvenance, @@ -92,15 +92,37 @@ function isToolResultHistoryBlockType(type: unknown): boolean { return normalized === "toolresult" || normalized === "tool_result"; } -function projectToolResultDiffDetails( +function projectToolResultDetails( details: unknown, maxChars: number, -): { diff: string } | undefined { +): Record | undefined { const record = readRecord(details); - if (!record || typeof record.diff !== "string" || !record.diff.trim()) { + if (!record) { return undefined; } - return { diff: truncateChatHistoryText(record.diff, maxChars).text }; + const projected: Record = {}; + if (typeof record.diff === "string" && record.diff.trim()) { + projected.diff = truncateChatHistoryText(record.diff, maxChars).text; + } + const preview = extractCanvasFromDetails(record); + if (preview?.mcpApp && preview.viewId) { + projected.mcpAppPreview = { + kind: "canvas", + view: { + id: preview.viewId, + ...(preview.url ? { url: preview.url } : {}), + ...(preview.title ? { title: preview.title } : {}), + }, + presentation: { + target: "assistant_message", + ...(preview.title ? { title: preview.title } : {}), + ...(preview.preferredHeight ? { preferred_height: preview.preferredHeight } : {}), + ...(preview.sandbox ? { sandbox: preview.sandbox } : {}), + }, + mcpApp: preview.mcpApp, + }; + } + return Object.keys(projected).length > 0 ? projected : undefined; } function messageHasToolResultShape(message: Record): boolean { @@ -161,6 +183,23 @@ function extractChatHistoryBlockText(message: unknown): string | undefined { return textParts.length > 0 ? textParts.join("\n") : undefined; } +function extractChatHistoryCanvasPreview(message: Record) { + const direct = extractCanvasFromDetails(message.details); + if (direct) { + return direct; + } + if (!Array.isArray(message.content)) { + return undefined; + } + for (const block of message.content) { + const preview = extractCanvasFromDetails(readRecord(block)?.details); + if (preview) { + return preview; + } + } + return undefined; +} + function appendCanvasBlockToAssistantHistoryMessage(params: { message: unknown; preview: ReturnType; @@ -279,13 +318,14 @@ export function augmentChatHistoryWithCanvasBlocks(messages: unknown[]): unknown ? entry.tool_name : undefined; const text = extractChatHistoryBlockText(entry); - const preview = extractCanvasFromText(text, toolName); + const detailsPreview = extractChatHistoryCanvasPreview(entry); + const preview = detailsPreview ?? extractCanvasFromText(text, toolName); if (!preview) { continue; } pending.push({ preview, - rawText: text ?? null, + rawText: detailsPreview ? null : (text ?? null), }); } if (pending.length > 0) { @@ -320,7 +360,7 @@ function sanitizeChatHistoryContentBlock( opts?.preserveExactToolPayload === true || isToolHistoryBlockType(entry.type); const maxChars = opts?.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS; if (isToolResultHistoryBlockType(entry.type) && "details" in entry) { - const projectedDetails = projectToolResultDiffDetails(entry.details, maxChars); + const projectedDetails = projectToolResultDetails(entry.details, maxChars); if (projectedDetails) { entry.details = projectedDetails; } else { @@ -544,7 +584,7 @@ function sanitizeChatHistoryMessage( if ("details" in entry) { const projectedDetails = messageHasToolResultShape(entry) - ? projectToolResultDiffDetails(entry.details, maxChars) + ? projectToolResultDetails(entry.details, maxChars) : undefined; if (projectedDetails) { entry.details = projectedDetails; diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts index 51bcfdb4ae84..c89204e8615d 100644 --- a/src/gateway/control-ui-csp.test.ts +++ b/src/gateway/control-ui-csp.test.ts @@ -8,6 +8,7 @@ describe("buildControlUiCspHeader", () => { it("blocks inline scripts while allowing inline styles", () => { const csp = buildControlUiCspHeader(); expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("frame-src 'self' http: https:"); expect(csp).toContain("script-src 'self'"); expect(csp).not.toContain("script-src 'self' 'unsafe-inline'"); expect(csp).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 8cdadd7cda66..7fe32c8faa56 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -62,6 +62,9 @@ export function buildControlUiCspHeader(opts?: { "base-uri 'none'", "object-src 'none'", "frame-ancestors 'none'", + // Gateway selection can move to a remote dedicated MCP Apps origin after + // this document loads. The component still validates the exact endpoint. + "frame-src 'self' http: https:", `script-src ${scriptTokens.join(" ")}`, "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: blob:", diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 4dba741544dd..5b7e8a41cce1 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -397,12 +397,17 @@ describe("handleControlUiHttpRequest", () => { )?.[1]; expect(typeof csp).toBe("string"); expect(String(csp)).toContain("frame-ancestors 'none'"); + expect(String(csp)).toContain("frame-src 'self'"); expect(String(csp)).toContain("script-src 'self'"); expect(String(csp)).toContain( "connect-src 'self' ws: wss: https://api.openai.com https://tweakcn.com", ); expect(String(csp)).not.toContain("https://*.tweakcn.com"); expect(String(csp)).not.toContain("script-src 'self' 'unsafe-inline'"); + expect(setHeader).toHaveBeenCalledWith( + "Permissions-Policy", + "camera=*, microphone=*, geolocation=*, clipboard-write=*", + ); expect(responseBody(end)).toContain('data-openclaw-terminal-enabled="false"'); }, }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 29fbfaba26fe..cf05f25439c7 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -229,6 +229,9 @@ function applyControlUiSecurityHeaders(res: ServerResponse) { res.setHeader("Content-Security-Policy", buildControlUiCspHeader()); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); + // The active Gateway may differ from the server that delivered this UI. + // Exact sandbox policies and iframe allow attributes still narrow delegation. + res.setHeader("Permissions-Policy", "camera=*, microphone=*, geolocation=*, clipboard-write=*"); } function sendJson(res: ServerResponse, status: number, body: unknown) { diff --git a/src/gateway/mcp-app-sandbox-http.test.ts b/src/gateway/mcp-app-sandbox-http.test.ts new file mode 100644 index 000000000000..0f64f6c68f81 --- /dev/null +++ b/src/gateway/mcp-app-sandbox-http.test.ts @@ -0,0 +1,50 @@ +import type { IncomingMessage } from "node:http"; +import { describe, expect, it } from "vitest"; +import { buildMcpAppSandboxPath } from "../agents/mcp-app-sandbox.js"; +import { handleMcpAppSandboxHttpRequest } from "./mcp-app-sandbox-http.js"; +import { makeMockHttpResponse } from "./test-http-response.js"; + +function request(url: string, method: "GET" | "HEAD" | "POST" = "GET") { + const { res, end, setHeader } = makeMockHttpResponse(); + handleMcpAppSandboxHttpRequest({ url, method } as IncomingMessage, res); + return { res, end, setHeader }; +} + +describe("MCP App sandbox HTTP origin", () => { + it("serves only the proxy endpoint with metadata-derived CSP", () => { + const result = request( + buildMcpAppSandboxPath({ + connectDomains: ["https://api.example.com"], + resourceDomains: ["https://cdn.example.com"], + }), + ); + + expect(result.res.statusCode).toBe(200); + const csp = result.setHeader.mock.calls.findLast( + (call) => call[0] === "Content-Security-Policy", + )?.[1]; + expect(String(csp)).toContain("connect-src https://api.example.com"); + expect(String(csp)).toContain("script-src 'self' 'unsafe-inline' https://cdn.example.com"); + expect(String(csp)).toContain("font-src 'self' https://cdn.example.com"); + expect(String(csp)).not.toContain("frame-ancestors"); + expect(result.setHeader).not.toHaveBeenCalledWith("X-Frame-Options", expect.anything()); + expect(result.setHeader).toHaveBeenCalledWith( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=(), clipboard-write=()", + ); + expect(result.end).toHaveBeenCalledWith( + expect.stringContaining("ui/notifications/sandbox-proxy-ready"), + ); + }); + + it("supports HEAD and rejects other paths, methods, and malformed policy", () => { + const head = request(buildMcpAppSandboxPath(), "HEAD"); + expect(head.res.statusCode).toBe(200); + expect(head.end).toHaveBeenCalledWith(undefined); + + expect(request("/", "GET").res.statusCode).toBe(404); + expect(request(buildMcpAppSandboxPath(), "POST").res.statusCode).toBe(404); + expect(request(`${buildMcpAppSandboxPath()}?csp=not-json`).res.statusCode).toBe(400); + expect(request("http://[", "GET").res.statusCode).toBe(400); + }); +}); diff --git a/src/gateway/mcp-app-sandbox-http.ts b/src/gateway/mcp-app-sandbox-http.ts new file mode 100644 index 000000000000..d15160dcda99 --- /dev/null +++ b/src/gateway/mcp-app-sandbox-http.ts @@ -0,0 +1,61 @@ +import { + createServer as createHttpServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from "node:http"; +import { createServer as createHttpsServer } from "node:https"; +import type { TlsOptions } from "node:tls"; +import { + buildMcpAppContentSecurityPolicy, + buildMcpAppSandboxProxyHtml, + decodeMcpAppSandboxCsp, + MCP_APP_SANDBOX_PATH, +} from "../agents/mcp-app-sandbox.js"; + +const MCP_APP_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), clipboard-write=()"; + +export function handleMcpAppSandboxHttpRequest(req: IncomingMessage, res: ServerResponse): void { + let url: URL; + try { + url = new URL(req.url ?? "/", "http://localhost"); + } catch { + res.statusCode = 400; + res.end("Bad Request"); + return; + } + if (url.pathname !== MCP_APP_SANDBOX_PATH || (req.method !== "GET" && req.method !== "HEAD")) { + res.statusCode = 404; + res.end("Not Found"); + return; + } + + let csp; + try { + csp = decodeMcpAppSandboxCsp(url.searchParams.get("csp")); + } catch { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("invalid MCP App sandbox policy"); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Content-Security-Policy", buildMcpAppContentSecurityPolicy(csp)); + res.setHeader("Permissions-Policy", MCP_APP_PERMISSIONS_POLICY); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + res.setHeader("Origin-Agent-Cluster", "?1"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.end(req.method === "HEAD" ? undefined : buildMcpAppSandboxProxyHtml()); +} + +/** Dedicated listener: this origin must never serve Control UI or authenticated Gateway data. */ +export function createMcpAppSandboxHttpServer(tlsOptions?: TlsOptions): HttpServer { + const handler = (req: IncomingMessage, res: ServerResponse) => { + handleMcpAppSandboxHttpRequest(req, res); + }; + return tlsOptions ? createHttpsServer(tlsOptions, handler) : createHttpServer(handler); +} diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index e631a51712b6..e56a5209ceef 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -100,6 +100,12 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "tools.catalog", scope: "operator.read" }, { name: "tools.effective", scope: "operator.read", startup: true }, { name: "tools.invoke", scope: "operator.write" }, + { name: "mcp.app.view", scope: "operator.read" }, + { name: "mcp.app.listTools", scope: "operator.read" }, + { name: "mcp.app.listResources", scope: "operator.read" }, + { name: "mcp.app.listResourceTemplates", scope: "operator.read" }, + { name: "mcp.app.readResource", scope: "operator.read" }, + { name: "mcp.app.callTool", scope: "operator.write" }, { name: "audit.list", scope: "operator.read" }, { name: "audit.activity.list", scope: "operator.read" }, { name: "tasks.list", scope: "operator.read" }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 0ecefbd03e41..b5c09b05fca0 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -255,6 +255,10 @@ const loadToolsInvokeHandlers = lazyHandlerModule( () => import("./server-methods/tools-invoke.js"), (module) => module.toolsInvokeHandlers, ); +const loadMcpAppHandlers = lazyHandlerModule( + () => import("./server-methods/mcp-app.js"), + (module) => module.mcpAppHandlers, +); const loadTtsHandlers = lazyHandlerModule( () => import("./server-methods/tts.js"), (module) => module.ttsHandlers, @@ -586,6 +590,17 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["tools.invoke"], loadHandlers: loadToolsInvokeHandlers, }), + ...createLazyCoreHandlers({ + methods: [ + "mcp.app.view", + "mcp.app.callTool", + "mcp.app.listTools", + "mcp.app.listResources", + "mcp.app.listResourceTemplates", + "mcp.app.readResource", + ], + loadHandlers: loadMcpAppHandlers, + }), ...createLazyCoreHandlers({ methods: [ "tts.status", diff --git a/src/gateway/server-methods/mcp-app.test.ts b/src/gateway/server-methods/mcp-app.test.ts new file mode 100644 index 000000000000..5938967a8154 --- /dev/null +++ b/src/gateway/server-methods/mcp-app.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + completeDeferredSessionMcpRuntimeRetirement: vi.fn(), + getMcpAppViewLease: vi.fn(), + peekSessionMcpRuntime: vi.fn(), +})); + +vi.mock("../../agents/mcp-ui-resource.js", () => ({ + getMcpAppViewLease: mocks.getMcpAppViewLease, + acquireMcpAppViewRequest: () => () => {}, +})); +vi.mock("../../agents/mcp-app-sandbox.js", () => ({ + buildMcpAppSandboxPath: () => "mcp-app-sandbox", +})); +vi.mock("../../agents/agent-bundle-mcp-runtime.js", () => ({ + completeDeferredSessionMcpRuntimeRetirement: mocks.completeDeferredSessionMcpRuntimeRetirement, + peekSessionMcpRuntime: mocks.peekSessionMcpRuntime, +})); + +import { mcpAppHandlers } from "./mcp-app.js"; + +const view = { + viewId: "cv_app", + sessionId: "session-1", + serverName: "demo", + toolName: "show", + uiResourceUri: "ui://demo/app", + html: "demo", + toolInput: { city: "Paris" }, + toolResult: { content: [{ type: "text", text: "ok" }] }, + expiresAtMs: Date.now() + 60_000, + requestWindowStartedAtMs: Date.now(), + requestCount: 0, + toolCallCount: 0, + activeRequests: 0, +}; + +function runtime() { + const releaseLease = vi.fn(); + return { + sessionId: "session-1", + mcpAppsEnabled: true, + markUsed: vi.fn(), + acquireLease: vi.fn(() => releaseLease), + getCatalog: vi.fn(async () => ({ + tools: [ + { serverName: "demo", toolName: "shared" }, + { serverName: "demo", toolName: "app-only", uiVisibility: ["app"] }, + { serverName: "demo", toolName: "model-only", uiVisibility: ["model"] }, + ], + })), + callTool: vi.fn(async (_serverName: string, toolName: string) => ({ + content: [{ type: "text", text: toolName }], + })), + listTools: vi.fn(async () => ({ + tools: [ + { name: "shared", inputSchema: { type: "object" } }, + { + name: "app-only", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: ["app"] } }, + }, + { + name: "model-only", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: ["model"] } }, + }, + ], + })), + }; +} + +async function invoke(method: keyof typeof mcpAppHandlers, params: Record) { + const respond = vi.fn(); + await mcpAppHandlers[method]({ + respond, + params, + context: { + getMcpAppSandboxPort: () => 18790, + getRuntimeConfig: () => ({ + mcp: { apps: { enabled: true, sandboxOrigin: "https://apps.example.com" } }, + }), + }, + } as never); + return respond; +} + +describe("MCP App gateway bridge", () => { + beforeEach(() => { + view.requestCount = 0; + view.toolCallCount = 0; + view.activeRequests = 0; + mocks.getMcpAppViewLease.mockReset().mockReturnValue(view); + mocks.completeDeferredSessionMcpRuntimeRetirement.mockReset().mockResolvedValue(false); + mocks.peekSessionMcpRuntime.mockReset().mockReturnValue(runtime()); + }); + + it("returns the ephemeral view payload only for the bound session", async () => { + const respond = await invoke("mcp.app.view", { + sessionKey: "agent:main:main", + viewId: "cv_app", + }); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + sandboxUrl: "mcp-app-sandbox", + sandboxPort: 18790, + sandboxOrigin: "https://apps.example.com", + html: "demo", + toolInput: { city: "Paris" }, + }), + ); + expect(mocks.getMcpAppViewLease).toHaveBeenCalledWith("cv_app", expect.any(Object)); + const activeRuntime = mocks.peekSessionMcpRuntime.mock.results[0]?.value; + expect(activeRuntime.acquireLease).toHaveBeenCalledOnce(); + expect(activeRuntime.acquireLease.mock.results[0]?.value).toHaveBeenCalledOnce(); + expect(mocks.completeDeferredSessionMcpRuntimeRetirement).toHaveBeenCalledWith(activeRuntime); + }); + + it("does not replace a completed bridge response with a cleanup error", async () => { + mocks.completeDeferredSessionMcpRuntimeRetirement.mockRejectedValueOnce( + new Error("dispose failed"), + ); + const respond = await invoke("mcp.app.callTool", { + sessionKey: "agent:main:main", + viewId: "cv_app", + toolName: "shared", + }); + + expect(respond.mock.calls[0]?.[0]).toBe(true); + expect(respond.mock.calls[0]?.[1]).toMatchObject({ + content: [{ type: "text", text: "shared" }], + }); + }); + + it("filters model-only tools from app discovery and execution", async () => { + const params = { sessionKey: "agent:main:main", viewId: "cv_app" }; + const listed = await invoke("mcp.app.listTools", params); + expect(listed.mock.calls[0]?.[1].tools.map((tool: { name: string }) => tool.name)).toEqual([ + "shared", + "app-only", + ]); + + const denied = await invoke("mcp.app.callTool", { ...params, toolName: "model-only" }); + expect(denied.mock.calls[0]?.[0]).toBe(false); + }); + + it("never creates a runtime for an expired view", async () => { + mocks.getMcpAppViewLease.mockReturnValue(undefined); + const respond = await invoke("mcp.app.view", { + sessionKey: "agent:main:main", + viewId: "expired", + }); + expect(respond.mock.calls[0]?.[0]).toBe(false); + }); +}); diff --git a/src/gateway/server-methods/mcp-app.ts b/src/gateway/server-methods/mcp-app.ts new file mode 100644 index 000000000000..e5d1fea63cd1 --- /dev/null +++ b/src/gateway/server-methods/mcp-app.ts @@ -0,0 +1,211 @@ +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; +import { + completeDeferredSessionMcpRuntimeRetirement, + peekSessionMcpRuntime, +} from "../../agents/agent-bundle-mcp-runtime.js"; +import type { McpCatalogTool, SessionMcpRuntime } from "../../agents/agent-bundle-mcp-types.js"; +import { buildMcpAppSandboxPath } from "../../agents/mcp-app-sandbox.js"; +import { + acquireMcpAppViewRequest, + getMcpAppViewLease, + type McpAppViewLease, +} from "../../agents/mcp-ui-resource.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { logWarn } from "../../logger.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function requireString(params: Record, key: string): string { + const value = params[key]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${key} is required`); + } + return value.trim(); +} + +function optionalCursor(params: Record): { cursor?: string } | undefined { + const cursor = params.cursor; + return typeof cursor === "string" && cursor.trim() ? { cursor: cursor.trim() } : undefined; +} + +function isAppCallableTool(tool: McpCatalogTool): boolean { + return tool.uiVisibility === undefined || tool.uiVisibility.includes("app"); +} + +function isAppCallableListedTool(tool: Tool): boolean { + const { _meta: metadata } = tool; + const ui = + metadata?.ui && typeof metadata.ui === "object" && !Array.isArray(metadata.ui) + ? (metadata.ui as { visibility?: unknown }) + : undefined; + const visibility = Array.isArray(ui?.visibility) + ? ui.visibility.filter( + (entry): entry is "app" | "model" => entry === "app" || entry === "model", + ) + : undefined; + return visibility === undefined || visibility.includes("app"); +} + +function requireActiveView(params: Record): { + runtime: SessionMcpRuntime; + view: McpAppViewLease; +} { + const sessionKey = requireString(params, "sessionKey"); + const viewId = requireString(params, "viewId"); + const runtime = peekSessionMcpRuntime({ sessionKey }); + if (!runtime || runtime.mcpAppsEnabled !== true) { + throw new Error("MCP App runtime is unavailable"); + } + const view = getMcpAppViewLease(viewId, runtime); + if (!view) { + throw new Error("MCP App view expired or is not authorized for this session"); + } + runtime.markUsed(); + return { runtime, view }; +} + +async function withActiveView( + params: Record, + kind: "read" | "tool", + operation: (active: { runtime: SessionMcpRuntime; view: McpAppViewLease }) => Promise | T, +): Promise { + const active = requireActiveView(params); + const release = acquireMcpAppViewRequest(active.view, kind); + const releaseRuntimeLease = active.runtime.acquireLease?.(); + try { + return await operation(active); + } finally { + release(); + releaseRuntimeLease?.(); + await completeDeferredSessionMcpRuntimeRetirement(active.runtime).catch((error: unknown) => { + // A completed app tool call may have side effects. Cleanup failure must + // never turn its successful response into an apparent retryable failure. + logWarn(`mcp-app: deferred runtime cleanup failed: ${formatErrorMessage(error)}`); + }); + } +} + +async function requireCallableTool( + runtime: SessionMcpRuntime, + serverName: string, + toolName: string, +): Promise { + const catalog = await runtime.getCatalog(); + const tool = catalog.tools.find( + (entry) => entry.serverName === serverName && entry.toolName === toolName, + ); + if (!tool || !isAppCallableTool(tool)) { + throw new Error(`MCP tool "${toolName}" is not app-callable`); + } + return tool; +} + +async function handle( + respond: Parameters[0]["respond"], + operation: () => Promise, +) { + try { + respond(true, await operation()); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } +} + +export const mcpAppHandlers: GatewayRequestHandlers = { + "mcp.app.view": async ({ respond, params, context }) => { + await handle( + respond, + async () => + await withActiveView(params, "read", ({ view }) => { + const sandboxPort = context.getMcpAppSandboxPort?.(); + if (sandboxPort === undefined) { + throw new Error("MCP App sandbox listener is unavailable; restart the Gateway"); + } + const configuredOrigin = context.getRuntimeConfig().mcp?.apps?.sandboxOrigin; + return { + sandboxUrl: buildMcpAppSandboxPath(view.csp), + sandboxPort, + ...(configuredOrigin ? { sandboxOrigin: new URL(configuredOrigin).origin } : {}), + html: view.html, + ...(view.csp ? { csp: view.csp } : {}), + toolInput: view.toolInput, + toolResult: view.toolResult, + }; + }), + ); + }, + "mcp.app.callTool": async ({ respond, params }) => { + await handle( + respond, + async () => + await withActiveView(params, "tool", async ({ runtime, view }) => { + const toolName = requireString(params, "toolName"); + await requireCallableTool(runtime, view.serverName, toolName); + return await runtime.callTool(view.serverName, toolName, params.arguments ?? {}); + }), + ); + }, + "mcp.app.listTools": async ({ respond, params }) => { + await handle( + respond, + async () => + await withActiveView(params, "read", async ({ runtime, view }) => { + if (!runtime.listTools) { + throw new Error("MCP tools/list is unavailable"); + } + const [listed, catalog] = await Promise.all([ + runtime.listTools(view.serverName, optionalCursor(params)), + runtime.getCatalog(), + ]); + const allowed = new Set( + catalog.tools + .filter((tool) => tool.serverName === view.serverName && isAppCallableTool(tool)) + .map((tool) => tool.toolName), + ); + return { + ...listed, + tools: listed.tools.filter( + (tool) => allowed.has(tool.name.trim()) && isAppCallableListedTool(tool), + ), + }; + }), + ); + }, + "mcp.app.listResources": async ({ respond, params }) => { + await handle( + respond, + async () => + await withActiveView(params, "read", async ({ runtime, view }) => { + if (!runtime.listResources) { + throw new Error("MCP resources/list is unavailable"); + } + const resources = await runtime.listResources(view.serverName); + return Array.isArray(resources) ? { resources } : resources; + }), + ); + }, + "mcp.app.listResourceTemplates": async ({ respond, params }) => { + await handle( + respond, + async () => + await withActiveView(params, "read", async ({ runtime, view }) => { + if (!runtime.listResourceTemplates) { + throw new Error("MCP resources/templates/list is unavailable"); + } + return await runtime.listResourceTemplates(view.serverName, optionalCursor(params)); + }), + ); + }, + "mcp.app.readResource": async ({ respond, params }) => { + await handle( + respond, + async () => + await withActiveView(params, "read", async ({ runtime, view }) => { + if (!runtime.readResource) { + throw new Error("MCP resources/read is unavailable"); + } + return await runtime.readResource(view.serverName, requireString(params, "uri")); + }), + ); + }, +}; diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 964e55c4dbcc..4c7134f5414b 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -662,6 +662,39 @@ describe("waitForAgentJob", () => { }); describe("augmentChatHistoryWithCanvasBlocks", () => { + it("projects sanitized MCP App detail previews without changing tool content", () => { + const preview = { + kind: "canvas", + view: { + id: "cv_app", + }, + presentation: { target: "assistant_message", sandbox: "scripts" }, + mcpApp: { viewId: "cv_app" }, + }; + const toolMessage = { + role: "toolResult", + toolName: "demo__show", + content: [{ type: "text", text: "original tool text" }], + details: { mcpAppPreview: preview, secret: "drop-me" }, + }; + const assistantMessage = { role: "assistant", content: "Done" }; + + const sanitized = sanitizeChatHistoryMessages([toolMessage]); + expect(sanitized[0]).toMatchObject({ + content: [{ type: "text", text: "original tool text" }], + details: { mcpAppPreview: preview }, + }); + expect(JSON.stringify(sanitized)).not.toContain("drop-me"); + + const augmented = augmentChatHistoryWithCanvasBlocks([sanitized[0], assistantMessage]); + expect(augmented[1]).toMatchObject({ + content: [ + { type: "text", text: "Done" }, + { type: "canvas", preview: { mcpApp: { viewId: "cv_app" } } }, + ], + }); + }); + it("ignores user messages that merely contain canvas-shaped text", () => { const previewJson = JSON.stringify({ kind: "canvas", diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 2750a9a4a929..8ef9f8012fc6 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -90,6 +90,7 @@ export type GatewayRequestContext = { cron: GatewayCronServiceContract; cronStorePath: string; getRuntimeConfig: () => OpenClawConfig; + getMcpAppSandboxPort?: () => number | undefined; resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution; isTerminalEnabled: () => boolean; execApprovalManager?: ExecApprovalManager; diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 319030bd0477..4f0a54d175bd 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -16,6 +16,7 @@ export type GatewayRequestContextParams = { deps: GatewayRequestContext["deps"]; runtimeState: Pick; getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"]; + getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"]; resolveTerminalLaunchPolicy: GatewayRequestContext["resolveTerminalLaunchPolicy"]; isTerminalEnabled: GatewayRequestContext["isTerminalEnabled"]; execApprovalManager: GatewayRequestContext["execApprovalManager"]; @@ -102,6 +103,7 @@ export function createGatewayRequestContext( return params.runtimeState.cronState.storePath; }, getRuntimeConfig: params.getRuntimeConfig, + getMcpAppSandboxPort: params.getMcpAppSandboxPort, resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy, isTerminalEnabled: params.isTerminalEnabled, execApprovalManager: params.execApprovalManager, diff --git a/src/gateway/server-runtime-state.test.ts b/src/gateway/server-runtime-state.test.ts index 3cbe92108a87..7d0ea6f4fa9d 100644 --- a/src/gateway/server-runtime-state.test.ts +++ b/src/gateway/server-runtime-state.test.ts @@ -124,4 +124,29 @@ describe("createGatewayRuntimeState", () => { expect(runtimeState.httpBindHosts).toEqual(["127.0.0.1"]); expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed to bind loopback alias ::1")); }); + + it("starts MCP Apps on a dedicated adjacent-port origin", async () => { + const runtimeState = await createGatewayRuntimeStateForTest(undefined, { + cfg: { mcp: { apps: { enabled: true } } }, + port: 18789, + }); + + expect(runtimeState.getMcpAppSandboxPort()).toBeUndefined(); + await runtimeState.startListening(); + + expect(runtimeState.getMcpAppSandboxPort()).toBe(18790); + expect(runtimeState.httpServers).toHaveLength(2); + expect(mocks.listenGatewayHttpServer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ bindHost: "127.0.0.1", port: 18789 }), + ); + expect(mocks.listenGatewayHttpServer).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + bindHost: "127.0.0.1", + port: 18790, + retryEaddrinuse: false, + }), + ); + }); }); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index bbdfd2551199..1b602a8090f3 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -9,6 +9,7 @@ import { import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; import { WebSocketServer } from "ws"; +import { resolveMcpAppSandboxPort } from "../agents/mcp-app-sandbox.js"; import type { CliDeps } from "../cli/deps.types.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "../plugins/registry.js"; @@ -26,6 +27,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js"; import type { ControlUiRootState } from "./control-ui.js"; import type { HooksConfigResolved } from "./hooks.js"; import type { AuthorizedGatewayHttpRequest } from "./http-auth-utils.js"; +import { createMcpAppSandboxHttpServer } from "./mcp-app-sandbox-http.js"; import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js"; import type { GatewayBroadcastFn, GatewayBroadcastToConnIdsFn } from "./server-broadcast-types.js"; import { createGatewayBroadcaster } from "./server-broadcast.js"; @@ -141,6 +143,7 @@ export async function createGatewayRuntimeState(params: { chatQueuedTurns: Map; toolEventRecipients: ReturnType; getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined; + getMcpAppSandboxPort: () => number | undefined; }> { pinActivePluginHttpRouteRegistry(params.pluginRegistry); pinActivePluginSessionExtensionRegistry(params.pluginRegistry); @@ -266,6 +269,7 @@ export async function createGatewayRuntimeState(params: { const workerPreauthConnectionBudget = createPreauthConnectionBudget(); const httpServers: HttpServer[] = []; + const gatewayHttpServers: HttpServer[] = []; const httpBindHosts: string[] = []; for (const _ of bindHosts) { const httpServer = createGatewayHttpServer({ @@ -304,8 +308,18 @@ export async function createGatewayRuntimeState(params: { rateLimiter: params.rateLimiter, log: params.log, }); + gatewayHttpServers.push(httpServer); httpServers.push(httpServer); } + const mcpAppSandboxServers = + params.cfg.mcp?.apps?.enabled === true + ? bindHosts.map(() => + createMcpAppSandboxHttpServer( + params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, + ), + ) + : []; + httpServers.push(...mcpAppSandboxServers); let workerIngressPort: number | undefined; const workerHttpServer = params.workerIngressEnabled ? createHttpServer((_req, res) => { @@ -321,10 +335,11 @@ export async function createGatewayRuntimeState(params: { log: params.log, }); } - const httpServer = httpServers[0]; + const httpServer = gatewayHttpServers[0]; if (!httpServer) { throw new Error("Gateway HTTP server failed to start"); } + let mcpAppSandboxPort: number | undefined; let startListeningPromise: Promise | null = null; const startListening = async (): Promise => { if (startListeningPromise) { @@ -346,7 +361,7 @@ export async function createGatewayRuntimeState(params: { const boundHosts = new Set(); for (const host of listenOrder) { const index = bindHosts.indexOf(host); - const server = httpServers[index]; + const server = gatewayHttpServers[index]; if (!server) { throw new Error(`Missing gateway HTTP server for bind host ${host}`); } @@ -374,6 +389,27 @@ export async function createGatewayRuntimeState(params: { if (httpBindHosts.length === 0) { throw new Error("Gateway HTTP server failed to start"); } + if (mcpAppSandboxServers.length > 0) { + mcpAppSandboxPort = resolveMcpAppSandboxPort( + params.port, + params.cfg.mcp?.apps?.sandboxPort, + ); + for (const host of httpBindHosts) { + const index = bindHosts.indexOf(host); + const server = mcpAppSandboxServers[index]; + if (!server) { + throw new Error(`Missing MCP App sandbox HTTP server for bind host ${host}`); + } + await listenGatewayHttpServer({ + httpServer: server, + bindHost: host, + port: mcpAppSandboxPort, + retryEaddrinuse: false, + serviceName: "MCP App sandbox", + endpointScheme: params.gatewayTls?.enabled ? "https" : "http", + }); + } + } if (workerHttpServer) { await listenGatewayHttpServer({ httpServer: workerHttpServer, @@ -440,6 +476,7 @@ export async function createGatewayRuntimeState(params: { workerIngressPort === undefined ? undefined : { host: "127.0.0.1" as const, port: workerIngressPort }, + getMcpAppSandboxPort: () => mcpAppSandboxPort, }; } catch (err) { // If state creation fails after pins are installed, release them immediately so later diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 71fb43fd1d7c..fd78f9b7d62c 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1017,6 +1017,7 @@ export async function startGatewayServer( chatQueuedTurns, toolEventRecipients, getWorkerIngressEndpoint, + getMcpAppSandboxPort, } = await startupTrace.measure("runtime.state", () => createGatewayRuntimeState({ cfg: cfgAtStart, @@ -1647,6 +1648,7 @@ export async function startGatewayServer( deps, runtimeState, getRuntimeConfig, + getMcpAppSandboxPort, resolveTerminalLaunchPolicy: terminalLaunchPolicy.resolve, isTerminalEnabled: terminalLaunchPolicy.isEnabled, execApprovalManager, diff --git a/src/gateway/server/http-listen.ts b/src/gateway/server/http-listen.ts index 03ad14f0dc1e..adc7869ff7bf 100644 --- a/src/gateway/server/http-listen.ts +++ b/src/gateway/server/http-listen.ts @@ -22,8 +22,17 @@ export async function listenGatewayHttpServer(params: { bindHost: string; port: number; retryEaddrinuse?: boolean; + serviceName?: string; + endpointScheme?: "http" | "https" | "ws" | "wss"; }) { - const { httpServer, bindHost, port, retryEaddrinuse = true } = params; + const { + httpServer, + bindHost, + port, + retryEaddrinuse = true, + serviceName = "gateway", + endpointScheme = "ws", + } = params; const maxRetries = retryEaddrinuse ? EADDRINUSE_MAX_RETRIES : 0; for (const attempt of Array.from({ length: maxRetries + 1 }, (_, index) => index)) { @@ -52,12 +61,12 @@ export async function listenGatewayHttpServer(params: { } if (code === "EADDRINUSE") { throw new GatewayLockError( - `another gateway instance is already listening on ws://${bindHost}:${port}`, + `another ${serviceName} instance is already listening on ${endpointScheme}://${bindHost}:${port}`, err, ); } throw new GatewayLockError( - `failed to bind gateway socket on ws://${bindHost}:${port}: ${String(err)}`, + `failed to bind ${serviceName} socket on ${endpointScheme}://${bindHost}:${port}: ${String(err)}`, err, ); } diff --git a/src/security/audit-gateway-config.ts b/src/security/audit-gateway-config.ts index ab74f05076c6..ee65e11936e7 100644 --- a/src/security/audit-gateway-config.ts +++ b/src/security/audit-gateway-config.ts @@ -279,6 +279,18 @@ export function collectGatewayConfigFindings( }); } + if (cfg.mcp?.apps?.enabled === true) { + findings.push({ + checkId: "mcp.apps.enabled", + severity: "warn", + title: "MCP Apps UI bridge enabled", + detail: + "mcp.apps.enabled=true allows configured MCP servers to provide interactive HTML. Views are CSP-restricted and origin-isolated, but they can call app-visible tools on their owning MCP server while the session runtime remains active.", + remediation: + "Keep this enabled only for MCP servers you trust. Disable with `openclaw config set mcp.apps.enabled false --strict-json` when it is not needed.", + }); + } + const enabledDangerousFlags = ( options.collectDangerousConfigFlags ?? collectCoreInsecureOrDangerousFlags )(cfg); diff --git a/src/security/audit-gateway-exposure.test.ts b/src/security/audit-gateway-exposure.test.ts index 2432808b2836..fda5ec28dd55 100644 --- a/src/security/audit-gateway-exposure.test.ts +++ b/src/security/audit-gateway-exposure.test.ts @@ -38,6 +38,15 @@ function requireFinding( } describe("security audit gateway exposure findings", () => { + it("warns when the MCP Apps bridge is enabled", () => { + const cfg: OpenClawConfig = { mcp: { apps: { enabled: true } } }; + expect(collectGatewayConfigFindings(cfg, cfg, {})).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: "mcp.apps.enabled", severity: "warn" }), + ]), + ); + }); + it("warns on insecure or dangerous flags", () => { const cases = [ { diff --git a/ui/package.json b/ui/package.json index 7fd400ba60d0..705cab004465 100644 --- a/ui/package.json +++ b/ui/package.json @@ -17,6 +17,8 @@ "@create-markdown/preview": "2.0.3", "@lezer/highlight": "1.2.3", "@lit/context": "1.1.6", + "@modelcontextprotocol/ext-apps": "1.7.4", + "@modelcontextprotocol/sdk": "1.29.0", "@noble/ed25519": "3.1.0", "@openclaw/libterminal": "0.3.1", "@openclaw/media-core": "workspace:*", diff --git a/ui/src/components/mcp-app-view.ts b/ui/src/components/mcp-app-view.ts new file mode 100644 index 000000000000..70b355c4e76e --- /dev/null +++ b/ui/src/components/mcp-app-view.ts @@ -0,0 +1,336 @@ +import { consume } from "@lit/context"; +import { AppBridge, PostMessageTransport } from "@modelcontextprotocol/ext-apps/app-bridge"; +import { + type CallToolResult, + type ListToolsRequest, + ListToolsRequestSchema, + type ListToolsResult, +} from "@modelcontextprotocol/sdk/types.js"; +import { LitElement, css, html, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { applicationContext, type ApplicationContext } from "../app/context.ts"; +import { openExternalUrlSafe } from "../lib/open-external-url.ts"; + +type McpAppViewPayload = { + sandboxUrl: string; + sandboxPort: number; + sandboxOrigin?: string; + html: string; + csp?: Record; + toolInput: unknown; + toolResult: unknown; +}; + +type HostContext = NonNullable< + NonNullable[3]>["hostContext"] +>; + +function hostContext(element: Element | undefined, height: number): HostContext { + const rect = element?.getBoundingClientRect(); + const touch = navigator.maxTouchPoints > 0 || window.matchMedia?.("(pointer: coarse)").matches; + return { + theme: window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light", + displayMode: "inline", + availableDisplayModes: ["inline"], + containerDimensions: { + width: Math.max(1, Math.round(rect?.width || window.innerWidth)), + height, + }, + locale: navigator.language || undefined, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + platform: touch && window.innerWidth < 768 ? "mobile" : "web", + deviceCapabilities: { + touch, + hover: window.matchMedia?.("(hover: hover)").matches, + }, + safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + }; +} + +export function resolveMcpAppSandboxUrl( + value: string, + sandboxPort: number, + sandboxOrigin: string | undefined, + gatewayUrl: string, + hostOrigin = window.location.origin, +): string { + if (!Number.isInteger(sandboxPort) || sandboxPort < 1 || sandboxPort > 65535) { + throw new Error("MCP App sandbox port is invalid"); + } + const gateway = new URL(gatewayUrl || hostOrigin, hostOrigin); + if (gateway.protocol === "ws:") { + gateway.protocol = "http:"; + } else if (gateway.protocol === "wss:") { + gateway.protocol = "https:"; + } + if (gateway.protocol !== "http:" && gateway.protocol !== "https:") { + throw new Error("MCP App sandbox URL is invalid"); + } + const activeGatewayOrigin = gateway.origin; + const base = sandboxOrigin ? new URL(sandboxOrigin) : new URL(activeGatewayOrigin); + if (sandboxOrigin) { + if ( + base.origin !== sandboxOrigin.replace(/\/$/u, "") || + base.username !== "" || + base.password !== "" + ) { + throw new Error("MCP App sandbox URL is invalid"); + } + } else { + base.port = String(sandboxPort); + } + base.pathname = "/"; + base.search = ""; + base.hash = ""; + const resolved = new URL(value, base); + if ( + (base.protocol !== "http:" && base.protocol !== "https:") || + base.origin === new URL(hostOrigin).origin || + base.origin === activeGatewayOrigin || + resolved.origin !== base.origin || + resolved.pathname !== "/mcp-app-sandbox" + ) { + throw new Error("MCP App sandbox URL is invalid"); + } + return resolved.href; +} + +class OpenClawAppBridge extends AppBridge { + setListToolsHandler(handler: (params: ListToolsRequest["params"]) => Promise) { + this.replaceRequestHandler(ListToolsRequestSchema, (request) => handler(request.params)); + } +} + +export class McpAppView extends LitElement { + static override styles = css` + :host { + display: block; + width: 100%; + } + .mount { + width: 100%; + min-height: 160px; + } + .mount:empty { + min-height: 0; + } + iframe { + display: block; + width: 100%; + border: 0; + background: transparent; + } + .error { + padding: 14px; + color: var(--danger, #dc2626); + font-size: 13px; + } + `; + + @consume({ context: applicationContext, subscribe: true }) + private context?: ApplicationContext; + + @property({ attribute: false }) sessionKey = ""; + @property({ attribute: false }) viewId = ""; + @property({ type: Number }) height = 600; + @property() override title = "MCP App"; + @state() private error: string | null = null; + + private readonly mount = createRef(); + private bridge: AppBridge | null = null; + private iframe: HTMLIFrameElement | null = null; + private transport: { close(): Promise } | null = null; + private setupKey = ""; + private setupClient: object | null = null; + private setupGeneration = 0; + + override disconnectedCallback() { + this.setupGeneration += 1; + void this.teardown(); + super.disconnectedCallback(); + } + + override updated() { + const nextKey = `${this.sessionKey}\0${this.viewId}`; + const nextClient = this.context?.gateway.snapshot.client ?? null; + if (nextKey !== this.setupKey || nextClient !== this.setupClient) { + this.setupKey = nextKey; + this.setupClient = nextClient; + void this.setup(); + } + } + + private async request(method: string, params: Record): Promise { + const client = this.context?.gateway.snapshot.client; + if (!client || !this.sessionKey || !this.viewId) { + throw new Error("MCP App gateway unavailable"); + } + return await client.request(method, { + sessionKey: this.sessionKey, + viewId: this.viewId, + ...params, + }); + } + + private async teardown() { + const bridge = this.bridge; + const transport = this.transport; + const iframe = this.iframe; + this.bridge = null; + this.transport = null; + this.iframe = null; + // Clear ownership before awaiting: a stale teardown must never close a + // replacement setup that installs its resources during the handshake. + iframe?.remove(); + if (bridge) { + await Promise.race([ + bridge.teardownResource({}).catch(() => undefined), + new Promise((resolve) => { + setTimeout(resolve, 250); + }), + ]); + } + await transport?.close().catch(() => undefined); + } + + private async setup() { + const generation = ++this.setupGeneration; + await this.teardown(); + if (!this.sessionKey || !this.viewId || generation !== this.setupGeneration) { + return; + } + try { + const payload = (await this.request("mcp.app.view", {})) as McpAppViewPayload; + const mount = this.mount.value; + if (!mount || generation !== this.setupGeneration) { + return; + } + const iframe = document.createElement("iframe"); + iframe.title = this.title; + iframe.referrerPolicy = "no-referrer"; + iframe.style.height = `${this.height}px`; + // The proxy listener is a dedicated origin that never serves host data, + // so Apps retain their required origin capabilities without reaching Control UI. + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + mount.appendChild(iframe); + this.iframe = iframe; + + const proxyReady = new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + window.removeEventListener("message", onMessage); + reject(new Error("MCP App sandbox timed out")); + }, 15_000); + const onMessage = (event: MessageEvent) => { + if ( + event.source === iframe.contentWindow && + event.data?.method === "ui/notifications/sandbox-proxy-ready" + ) { + window.clearTimeout(timeout); + window.removeEventListener("message", onMessage); + resolve(); + } + }; + window.addEventListener("message", onMessage); + }); + iframe.src = resolveMcpAppSandboxUrl( + payload.sandboxUrl, + payload.sandboxPort, + payload.sandboxOrigin, + this.context?.gateway.connection.gatewayUrl ?? "", + ); + await proxyReady; + if (!iframe.contentWindow || generation !== this.setupGeneration) { + return; + } + + const bridge = new OpenClawAppBridge( + null, + { name: "OpenClaw", version: "1.0.0" }, + { openLinks: {}, serverResources: {}, serverTools: {} }, + { hostContext: hostContext(mount, this.height) }, + ); + bridge.oncalltool = async (params) => + (await this.request("mcp.app.callTool", { + toolName: params.name, + arguments: params.arguments, + })) as CallToolResult; + bridge.setListToolsHandler( + async (params) => + (await this.request( + "mcp.app.listTools", + params?.cursor ? { cursor: params.cursor } : {}, + )) as ListToolsResult, + ); + bridge.onlistresources = async (params) => + (await this.request( + "mcp.app.listResources", + params?.cursor ? { cursor: params.cursor } : {}, + )) as never; + bridge.onlistresourcetemplates = async (params) => + (await this.request( + "mcp.app.listResourceTemplates", + params?.cursor ? { cursor: params.cursor } : {}, + )) as never; + bridge.onreadresource = async (params) => + (await this.request("mcp.app.readResource", { uri: params.uri })) as never; + bridge.onopenlink = async ({ url }) => (openExternalUrlSafe(url) ? {} : { isError: true }); + bridge.onsizechange = ({ height }) => { + if (height !== undefined) { + const nextHeight = Math.min(1200, Math.max(160, Math.round(height))); + iframe.style.height = `${nextHeight}px`; + bridge.setHostContext(hostContext(mount, nextHeight)); + } + }; + const initialized = new Promise((resolve) => { + bridge.oninitialized = () => resolve(); + }); + const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow); + this.bridge = bridge; + this.transport = transport; + await bridge.connect(transport); + await bridge.sendSandboxResourceReady({ + html: payload.html, + csp: payload.csp, + }); + await Promise.race([ + initialized, + new Promise((_, reject) => { + window.setTimeout(() => reject(new Error("MCP App initialization timed out")), 15_000); + }), + ]); + await bridge.sendToolInput({ + arguments: + payload.toolInput && + typeof payload.toolInput === "object" && + !Array.isArray(payload.toolInput) + ? (payload.toolInput as Record) + : {}, + }); + await bridge.sendToolResult(payload.toolResult as never); + if (generation === this.setupGeneration) { + this.error = null; + } + } catch (error) { + if (generation === this.setupGeneration) { + await this.teardown(); + this.error = error instanceof Error ? error.message : String(error); + } + } + } + + override render() { + return html`
+ ${this.error ? html`
MCP App unavailable: ${this.error}
` : nothing}`; + } +} + +if (!customElements.get("mcp-app-view")) { + customElements.define("mcp-app-view", McpAppView); +} + +declare global { + interface HTMLElementTagNameMap { + "mcp-app-view": McpAppView; + } +} diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 8620597688bc..cbb96f5eb314 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -163,6 +163,7 @@ export type ToolCard = { className?: string; style?: string; sandbox?: "strict" | "scripts"; + mcpApp?: { viewId: string }; }; }; diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index cb2b5eed59dc..54bed9132ee9 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -85,6 +85,10 @@ function coerceCanvasPreview( if (!render) { return null; } + const mcpApp = + preview.mcpApp && typeof preview.mcpApp === "object" && !Array.isArray(preview.mcpApp) + ? (preview.mcpApp as Record) + : undefined; return { kind: "canvas", surface: "assistant_message", @@ -100,6 +104,9 @@ function coerceCanvasPreview( ...(preview.sandbox === "strict" || preview.sandbox === "scripts" ? { sandbox: preview.sandbox } : {}), + ...(typeof mcpApp?.viewId === "string" && mcpApp.viewId.trim() + ? { mcpApp: { viewId: mcpApp.viewId } } + : {}), }; } diff --git a/ui/src/lib/chat/tool-cards.ts b/ui/src/lib/chat/tool-cards.ts index 923c25062b86..bc37727ab16b 100644 --- a/ui/src/lib/chat/tool-cards.ts +++ b/ui/src/lib/chat/tool-cards.ts @@ -1,6 +1,9 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; // Control UI chat domain owns pure tool-card extraction rules. -import { extractCanvasFromText } from "../../../../src/chat/canvas-render.js"; +import { + extractCanvasFromDetails, + extractCanvasFromText, +} from "../../../../src/chat/canvas-render.js"; import { isToolCallContentType, isToolResultContentType, @@ -319,9 +322,9 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] const callId = resolveToolCallId(item, m); const existing = findFirstUnmatchedCard(cards, cardId, name, fallbackMatchedCards); const text = extractToolText(item); - const preview = extractToolPreview(text, name); - const isError = readToolErrorFlag(item) ?? messageIsError; const details = item.details ?? m.details; + const preview = extractCanvasFromDetails(details) ?? extractToolPreview(text, name); + const isError = readToolErrorFlag(item) ?? messageIsError; if (existing) { fallbackMatchedCards.add(existing); existing.callId ??= callId; @@ -374,7 +377,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[] ...(m.details !== undefined ? { details: m.details } : {}), messageId: transcriptMessageId, ...(messageIsError !== undefined ? { isError: messageIsError } : {}), - preview: extractToolPreview(text, name), + preview: extractCanvasFromDetails(m.details) ?? extractToolPreview(text, name), }); } diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index ea098b6acb20..2b3fcfd2c7e9 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -2166,6 +2166,7 @@ function renderGroupedMessage( rawText: block.rawText ?? null, canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl, embedSandboxMode: opts.embedSandboxMode ?? "scripts", + sessionKey: opts.sessionKey, })} ${block.rawText ? renderRawOutputToggle(block.rawText) : nothing}`, )}` diff --git a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts index 53120655863c..0fdd01da6329 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.node.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.node.test.ts @@ -453,6 +453,34 @@ with Example Deck expect(card?.outputText).toBe("Opened page"); }); + it("extracts MCP App previews from sanitized result details", () => { + const [card] = extractToolCards( + { + role: "tool", + toolName: "demo__show", + content: [{ type: "text", text: "original result" }], + details: { + mcpAppPreview: { + kind: "canvas", + view: { + id: "cv_app", + }, + presentation: { target: "assistant_message", sandbox: "scripts" }, + mcpApp: { viewId: "cv_app" }, + }, + }, + }, + "msg:mcp-app", + ); + + expect(card?.outputText).toBe("original result"); + expect(card?.preview).toMatchObject({ + viewId: "cv_app", + mcpApp: { viewId: "cv_app" }, + sandbox: "scripts", + }); + }); + it("does not create previews for non-assistant canvas or generic outputs", () => { const cases = [ { diff --git a/ui/src/pages/chat/components/chat-tool-cards.test.ts b/ui/src/pages/chat/components/chat-tool-cards.test.ts index 9de31ebd860a..d2421efd91ac 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.test.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.test.ts @@ -28,13 +28,14 @@ vi.mock("../tool-display.ts", () => ({ }, })); +import { resolveMcpAppSandboxUrl } from "../../../components/mcp-app-view.ts"; import { formatDistinctCollapsedToolSummaryText, formatCollapsedToolPreviewText, formatCollapsedToolSummaryText, isToolErrorOutput, } from "../../../lib/chat/tool-cards.ts"; -import { renderToolCard } from "./chat-tool-cards.ts"; +import { renderToolCard, renderToolPreview } from "./chat-tool-cards.ts"; function requireFirstMockArg( mock: ReturnType, @@ -64,6 +65,102 @@ function pointerClick(element: Element) { } describe("tool-cards", () => { + it("accepts only the dedicated-origin MCP App sandbox endpoint", () => { + expect( + resolveMcpAppSandboxUrl( + "/mcp-app-sandbox?csp=abc", + 8444, + undefined, + "wss://gateway.example:8443/openclaw", + "https://gateway.example:8443", + ), + ).toBe("https://gateway.example:8444/mcp-app-sandbox?csp=abc"); + expect( + resolveMcpAppSandboxUrl( + "/mcp-app-sandbox", + 18790, + "https://apps.example.com", + "wss://gateway.example", + "https://gateway.example", + ), + ).toBe("https://apps.example.com/mcp-app-sandbox"); + expect(() => + resolveMcpAppSandboxUrl( + "https://attacker.example/mcp-app-sandbox", + 8444, + undefined, + "wss://gateway.example:8443/openclaw", + "https://gateway.example:8443", + ), + ).toThrow("MCP App sandbox URL is invalid"); + expect(() => + resolveMcpAppSandboxUrl( + "data:text/html;base64,cHJveHk=", + 8444, + undefined, + "wss://gateway.example:8443/openclaw", + "https://gateway.example:8443", + ), + ).toThrow("MCP App sandbox URL is invalid"); + expect(() => + resolveMcpAppSandboxUrl( + "/mcp-app-sandbox", + 8443, + undefined, + "wss://gateway.example:8443/openclaw", + "https://gateway.example:8443", + ), + ).toThrow("MCP App sandbox URL is invalid"); + expect(() => + resolveMcpAppSandboxUrl( + "/mcp-app-sandbox", + 8444, + "https://gateway.example:8443", + "wss://gateway.example:8443/openclaw", + "https://control.example", + ), + ).toThrow("MCP App sandbox URL is invalid"); + }); + + it("routes MCP App previews through the dedicated double-iframe host", async () => { + const container = document.createElement("div"); + render( + renderToolPreview( + { + kind: "canvas", + surface: "assistant_message", + render: "url", + viewId: "cv_app", + mcpApp: { viewId: "cv_app" }, + }, + "chat_message", + { sessionKey: "agent:main:main" }, + ), + container, + ); + + const view = container.querySelector("mcp-app-view"); + expect(view).not.toBeNull(); + expect(view?.getAttribute("src")).toBeNull(); + expect((view as { viewId?: string }).viewId).toBe("cv_app"); + + const toolContainer = document.createElement("div"); + render( + renderToolPreview( + { + kind: "canvas", + surface: "assistant_message", + render: "url", + mcpApp: { viewId: "cv_app" }, + }, + "chat_tool", + { sessionKey: "agent:main:main" }, + ), + toolContainer, + ); + expect(toolContainer.querySelector("mcp-app-view")).toBeNull(); + }); + it("keeps selected summary text from toggling the disclosure", () => { const container = document.createElement("div"); document.body.append(container); diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index db1412071fae..9327c126aa67 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -5,6 +5,7 @@ import { keyed } from "lit/directives/keyed.js"; import { icons, type IconName } from "../../../components/icons.ts"; import { isMarkdownBlockArtText } from "../../../components/markdown.ts"; import "../../../components/tooltip.ts"; +import "../../../components/mcp-app-view.ts"; import { t } from "../../../i18n/index.ts"; import type { ToolCard, ToolCardOutcome } from "../../../lib/chat/chat-types.ts"; import { resolveToolCallView, type ToolCallView } from "../../../lib/chat/tool-call-view.ts"; @@ -236,12 +237,17 @@ export function renderToolPreview( canvasPluginSurfaceUrl?: string | null; embedSandboxMode?: EmbedSandboxMode; allowExternalEmbedUrls?: boolean; + sessionKey?: string; }, ) { if (!preview) { return nothing; } - if (preview.kind !== "canvas" || surface === "chat_tool") { + if ( + preview.kind !== "canvas" || + surface === "chat_tool" || + (preview.mcpApp && surface !== "chat_message") + ) { return nothing; } if (preview.surface !== "assistant_message") { @@ -253,16 +259,23 @@ export function renderToolPreview( ${preview.title?.trim() || "Canvas"}
- ${renderPreviewFrame({ - title: preview.title?.trim() || "Canvas", - src: resolveCanvasIframeUrl( - preview.url, - options?.canvasPluginSurfaceUrl, - options?.allowExternalEmbedUrls ?? false, - ), - height: preview.preferredHeight, - sandbox: resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts", preview.sandbox), - })} + ${preview.mcpApp + ? html`` + : renderPreviewFrame({ + title: preview.title?.trim() || "Canvas", + src: resolveCanvasIframeUrl( + preview.url, + options?.canvasPluginSurfaceUrl, + options?.allowExternalEmbedUrls ?? false, + ), + height: preview.preferredHeight, + sandbox: resolveEmbedSandbox(options?.embedSandboxMode ?? "scripts", preview.sandbox), + })}
`; @@ -826,6 +839,7 @@ export function renderExpandedToolCardContent( canvasPluginSurfaceUrl, embedSandboxMode, allowExternalEmbedUrls, + sessionKey, }) : nothing; const sidebarAction = canOpenSidebar