mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+29
@@ -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)':
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
export {
|
||||
testing,
|
||||
testing as __testing,
|
||||
completeDeferredSessionMcpRuntimeRetirement,
|
||||
createSessionMcpRuntime,
|
||||
disposeAllSessionMcpRuntimes,
|
||||
disposeSessionMcpRuntime,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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("; ");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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"');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ export type ToolCard = {
|
||||
className?: string;
|
||||
style?: string;
|
||||
sandbox?: "strict" | "scripts";
|
||||
mcpApp?: { viewId: string };
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user