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 <steipete@gmail.com>
Co-authored-by: Sally O'Malley <11166065+sallyom@users.noreply.github.com>
This commit is contained in:
Sally O'Malley
2026-07-12 01:04:38 -04:00
committed by GitHub
parent dd5928711a
commit f3971bbd56
51 changed files with 2565 additions and 54 deletions
@@ -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
+54
View File
@@ -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.
+1
View File
@@ -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
+29
View File
@@ -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)':
+24 -1
View File
@@ -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<string, unknown>).mcpAppPreview =
buildMcpAppCanvasPayload(view);
}
}
return agentResult;
},
createResourceListExecute: params.runtime.listResources
? (serverName) => async () => {
+124 -1
View File
@@ -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<typeof testing.createSessionMcpRuntimeManager>[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<string, unknown>;
}>;
capabilities?: Record<string, unknown>;
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",
+136 -17
View File
@@ -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, unknown>): string {
function createCatalogFingerprint(params: {
servers: Record<string, unknown>;
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<string, SessionMcpRuntime>();
const sessionIdBySessionKey = new Map<string, string>();
const idleTtlMsBySessionId = new Map<string, number>();
const deferredRetirementSessionIds = new Set<string>();
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<void> => {
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<void>
export async function retireSessionMcpRuntime(params: {
sessionId?: string | null;
reason: string;
preserveActiveLeases?: boolean;
onError?: (error: unknown, sessionId: string, reason: string) => void;
}): Promise<boolean> {
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<boolean> {
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<boolean> {
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<void> {
}
export const testing = {
buildMcpClientCapabilities,
createSessionMcpRuntimeManager,
async resetSessionMcpRuntimeManager() {
await disposeAllSessionMcpRuntimes();
@@ -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(),
+1
View File
@@ -11,6 +11,7 @@ export type {
export {
testing,
testing as __testing,
completeDeferredSessionMcpRuntimeRetirement,
createSessionMcpRuntime,
disposeAllSessionMcpRuntimes,
disposeSessionMcpRuntime,
+15 -1
View File
@@ -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<CallToolResult>;
listTools?: (serverName: string, params?: { cursor?: string }) => Promise<ListToolsResult>;
listResources?: (serverName: string) => Promise<unknown>;
readResource?: (serverName: string, uri: string) => Promise<unknown>;
listResourceTemplates?: (
serverName: string,
params?: { cursor?: string },
) => Promise<ListResourceTemplatesResult>;
listPrompts?: (serverName: string) => Promise<unknown>;
getPrompt?: (serverName: string, name: string, args?: Record<string, string>) => Promise<unknown>;
dispose: () => Promise<void>;
@@ -103,6 +115,8 @@ export type SessionMcpRuntimeManager = {
sessionKey?: string;
}) => SessionMcpRuntime | undefined;
disposeSession: (sessionId: string) => Promise<void>;
deferRetirement: (sessionId: string) => boolean;
completeDeferredRetirement: (sessionId: string, runtime: SessionMcpRuntime) => Promise<boolean>;
disposeAll: () => Promise<void>;
sweepIdleRuntimes: () => Promise<number>;
listSessionIds: () => string[];
+4
View File
@@ -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,
});
}
+172
View File
@@ -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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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 `<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>MCP App sandbox</title>
<style>html,body{height:100%;margin:0;background:transparent}iframe{display:block;width:100%;height:100%;border:0;background:transparent}</style>
<body>
<script>
(() => {
if (window.self === window.top) throw new Error("invalid MCP App sandbox host");
let hostOrigin = null;
try { void window.top.document; throw new Error("MCP App sandbox isolation failed"); } catch (error) {
if (error instanceof Error && error.message === "MCP App sandbox isolation failed") throw error;
}
const inner = document.createElement("iframe");
inner.setAttribute("sandbox", "allow-scripts allow-forms");
document.body.appendChild(inner);
window.addEventListener("message", (event) => {
if (event.source === window.parent) {
if (hostOrigin === null) hostOrigin = event.origin;
if (event.origin !== hostOrigin) return;
if (event.data?.method === "ui/notifications/sandbox-resource-ready") {
const params = event.data.params ?? {};
if (typeof params.html === "string") {
inner.srcdoc = params.html;
}
return;
}
if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return;
inner.contentWindow?.postMessage(event.data, "*");
return;
}
if (event.source === inner.contentWindow && hostOrigin !== null) {
if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return;
window.parent.postMessage(event.data, hostOrigin);
}
});
window.parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} }, "*");
})();
</script>
</body>`;
}
/** 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("; ");
}
+280
View File
@@ -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: "<html>demo</html>",
_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: "<html>demo</html>",
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: "<html></html>",
},
{
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<typeof acquireMcpAppViewRequest>[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: "<!doctype html><script>globalThis.ready = true</script>",
_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("<!doctype html>")).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('<!doctype html>\n<meta charset="utf-8"')).toBe(true);
expect(proxyHtml).toContain('inner.setAttribute("sandbox", "allow-scripts allow-forms")');
expect(proxyHtml).toContain("inner.srcdoc = params.html");
expect(proxyHtml).not.toContain("doc.write");
expect(proxyHtml).not.toContain("params.sandbox");
expect(proxyHtml).not.toContain("params.permissions");
expect(proxyHtml).toContain('startsWith("ui/notifications/sandbox-")');
});
it("deletes sensitive view data when the lease expires without later activity", async () => {
vi.useFakeTimers();
const sessionRuntime = runtime(async () => ({
contents: [
{
uri: "ui://demo/app",
mimeType: MCP_APP_RESOURCE_MIME_TYPE,
text: "<html>secret</html>",
},
],
}));
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: "<html>demo</html>",
},
],
}));
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();
});
});
+299
View File
@@ -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<string, never>>
>;
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<typeof setTimeout>;
releaseRuntimeLease?: () => void;
};
type McpAppViewStore = Map<string, McpAppViewLease>;
function getViewStore(): McpAppViewStore {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const existing = globalStore[MCP_APP_VIEW_STORE_KEY] as McpAppViewStore | undefined;
if (existing) {
return existing;
}
const store = new Map<string, McpAppViewLease>();
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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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, unknown>): 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<Record<string, unknown> | 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 };
+38 -1
View File
@@ -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", () => {
+23
View File
@@ -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<string, string> {
const attrs: Record<string, string> = {};
const re = /([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
+33
View File
@@ -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({
+8
View File
@@ -623,6 +623,14 @@ export const FIELD_HELP: Record<string, string> = {
"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":
+4
View File
@@ -383,6 +383,10 @@ export const FIELD_LABELS: Record<string, string> = {
"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",
+3
View File
@@ -60,6 +60,9 @@ const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"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"],
+8
View File
@@ -82,6 +82,14 @@ export type McpServerConfig = {
export type McpConfig = {
/** Named MCP server definitions managed by OpenClaw. */
servers?: Record<string, McpServerConfig>;
/** 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.
*
+24
View File
@@ -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()
+49 -9
View File
@@ -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<string, unknown> | 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<string, unknown> = {};
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<string, unknown>): boolean {
@@ -161,6 +183,23 @@ function extractChatHistoryBlockText(message: unknown): string | undefined {
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
function extractChatHistoryCanvasPreview(message: Record<string, unknown>) {
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<typeof extractCanvasFromText>;
@@ -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;
+1
View File
@@ -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");
+3
View File
@@ -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:",
+5
View File
@@ -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"');
},
});
+3
View File
@@ -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) {
+50
View File
@@ -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);
});
});
+61
View File
@@ -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);
}
+6
View File
@@ -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" },
+15
View File
@@ -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",
+157
View File
@@ -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: "<html>demo</html>",
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<string, unknown>) {
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: "<html>demo</html>",
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);
});
});
+211
View File
@@ -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<string, unknown>, 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<string, unknown>): { 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<string, unknown>): {
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<T>(
params: Record<string, unknown>,
kind: "read" | "tool",
operation: (active: { runtime: SessionMcpRuntime; view: McpAppViewLease }) => Promise<T> | T,
): Promise<T> {
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<McpCatalogTool> {
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<GatewayRequestHandlers[string]>[0]["respond"],
operation: () => Promise<unknown>,
) {
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"));
}),
);
},
};
@@ -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",
@@ -90,6 +90,7 @@ export type GatewayRequestContext = {
cron: GatewayCronServiceContract;
cronStorePath: string;
getRuntimeConfig: () => OpenClawConfig;
getMcpAppSandboxPort?: () => number | undefined;
resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution;
isTerminalEnabled: () => boolean;
execApprovalManager?: ExecApprovalManager;
+2
View File
@@ -16,6 +16,7 @@ export type GatewayRequestContextParams = {
deps: GatewayRequestContext["deps"];
runtimeState: Pick<GatewayServerLiveState, "cronState" | "configReloader">;
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,
+25
View File
@@ -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,
}),
);
});
});
+39 -2
View File
@@ -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<string, import("./chat-queued-turns.js").QueuedChatTurnEntry>;
toolEventRecipients: ReturnType<typeof createToolEventRecipientRegistry>;
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<void> | null = null;
const startListening = async (): Promise<void> => {
if (startListeningPromise) {
@@ -346,7 +361,7 @@ export async function createGatewayRuntimeState(params: {
const boundHosts = new Set<string>();
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
+2
View File
@@ -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,
+12 -3
View File
@@ -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,
);
}
+12
View File
@@ -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);
@@ -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 = [
{
+2
View File
@@ -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:*",
+336
View File
@@ -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<string, unknown>;
toolInput: unknown;
toolResult: unknown;
};
type HostContext = NonNullable<
NonNullable<ConstructorParameters<typeof AppBridge>[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<ListToolsResult>) {
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<HTMLDivElement>();
private bridge: AppBridge | null = null;
private iframe: HTMLIFrameElement | null = null;
private transport: { close(): Promise<void> } | 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<string, unknown>): Promise<unknown> {
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<void>((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<void>((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<never>((_, 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<string, unknown>)
: {},
});
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`<div ${ref(this.mount)} class="mount"></div>
${this.error ? html`<div class="error">MCP App unavailable: ${this.error}</div>` : nothing}`;
}
}
if (!customElements.get("mcp-app-view")) {
customElements.define("mcp-app-view", McpAppView);
}
declare global {
interface HTMLElementTagNameMap {
"mcp-app-view": McpAppView;
}
}
+1
View File
@@ -163,6 +163,7 @@ export type ToolCard = {
className?: string;
style?: string;
sandbox?: "strict" | "scripts";
mcpApp?: { viewId: string };
};
};
+7
View File
@@ -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<string, unknown>)
: 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 } }
: {}),
};
}
+7 -4
View File
@@ -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),
});
}
@@ -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}`,
)}`
@@ -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 = [
{
@@ -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<typeof vi.fn>,
@@ -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);
+25 -11
View File
@@ -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(
<span class="chat-tool-card__preview-label">${preview.title?.trim() || "Canvas"}</span>
</div>
<div class="chat-tool-card__preview-panel" data-side="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`<mcp-app-view
.sessionKey=${options?.sessionKey ?? ""}
.viewId=${preview.mcpApp.viewId}
.height=${preview.preferredHeight ?? 600}
.title=${preview.title?.trim() || "MCP App"}
></mcp-app-view>`
: 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),
})}
</div>
</div>
`;
@@ -826,6 +839,7 @@ export function renderExpandedToolCardContent(
canvasPluginSurfaceUrl,
embedSandboxMode,
allowExternalEmbedUrls,
sessionKey,
})
: nothing;
const sidebarAction = canOpenSidebar