fix(ui): preserve MCP App previews across reloads (#105728)

* fix(mcp): harden app preview lifecycle

* fix(ui): defer MCP app initialization payload

* test(mcp): align current policy expectations

* fix(ui): preserve MCP app previews

* fix(mcp): reject disabled app restoration

* fix(mcp): preserve reconstructed app state

* fix(mcp): bind reconstructable app data

* fix(ui): deduplicate MCP app preview copies

* style(ui): format MCP app preview test

* fix(mcp): satisfy metadata access lint

* style(mcp): format metadata access
This commit is contained in:
Jason (Json)
2026-07-12 17:30:34 -06:00
committed by GitHub
parent cca22d93f6
commit 1663c8801d
19 changed files with 1498 additions and 83 deletions

View File

@@ -414,7 +414,7 @@ export async function materializeBundleMcpToolsForRun(params: {
const tools = buildBundleMcpToolsFromCatalog({
catalog,
reservedToolNames,
createExecute: (tool) => async (_toolCallId: string, input: unknown) => {
createExecute: (tool) => async (toolCallId: string, input: unknown) => {
params.runtime.markUsed();
const result = await params.runtime.callTool(tool.serverName, tool.toolName, input);
const agentResult = toAgentToolResult({
@@ -431,13 +431,18 @@ export async function materializeBundleMcpToolsForRun(params: {
serverName: tool.serverName,
toolName: tool.toolName,
uiResourceUri: tool.uiResourceUri,
toolCallId,
toolInput: input,
toolResult: result,
...(allowedAppToolNames ? { allowedAppToolNames } : {}),
});
if (view) {
(agentResult.details as Record<string, unknown>).mcpAppPreview =
buildMcpAppCanvasPayload(view);
(agentResult.details as Record<string, unknown>).mcpAppPreview = buildMcpAppCanvasPayload(
{
...view,
...(result["_meta"] !== undefined ? { resultMetaState: "unavailable" as const } : {}),
},
);
}
}
return agentResult;

View File

@@ -19,11 +19,26 @@ const mcpAppMocks = vi.hoisted(() => ({ fetchMcpAppView: vi.fn() }));
vi.mock("./mcp-ui-resource.js", () => ({
fetchMcpAppView: mcpAppMocks.fetchMcpAppView,
buildMcpAppCanvasPayload: (view: { viewId: string; title: string }) => ({
buildMcpAppCanvasPayload: (view: {
viewId: string;
title: string;
serverName: string;
toolName: string;
uiResourceUri: string;
toolCallId?: string;
resultMetaState?: "unavailable";
}) => ({
kind: "canvas",
view: { id: view.viewId, title: view.title },
presentation: { target: "assistant_message", sandbox: "scripts" },
mcpApp: { viewId: view.viewId },
mcpApp: {
viewId: view.viewId,
serverName: view.serverName,
toolName: view.toolName,
uiResourceUri: view.uiResourceUri,
...(view.toolCallId ? { toolCallId: view.toolCallId } : {}),
...(view.resultMetaState ? { resultMetaState: view.resultMetaState } : {}),
},
}),
}));
@@ -152,6 +167,10 @@ describe("createBundleMcpToolRuntime", () => {
mcpAppMocks.fetchMcpAppView.mockResolvedValue({
viewId: "cv_app",
title: "Demo UI",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
});
const tool: McpCatalogTool = {
serverName: "demo",
@@ -166,6 +185,7 @@ describe("createBundleMcpToolRuntime", () => {
serverName: "demo",
result: {
content: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }],
_meta: { "ui/state": { selected: true } },
},
});
sessionRuntime.mcpAppsEnabled = true;
@@ -178,10 +198,22 @@ describe("createBundleMcpToolRuntime", () => {
).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" } },
mcpAppPreview: {
mcpApp: {
viewId: "cv_app",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
resultMetaState: "unavailable",
},
},
});
expect(mcpAppMocks.fetchMcpAppView).toHaveBeenCalledWith(
expect.objectContaining({ allowedAppToolNames: new Set(["show"]) }),
expect.objectContaining({
toolCallId: "call-1",
allowedAppToolNames: new Set(["show"]),
}),
);
});

View File

@@ -113,7 +113,12 @@ export function buildMcpAppSandboxProxyHtml(): string {
<script>
(() => {
if (window.self === window.top) throw new Error("invalid MCP App sandbox host");
let hostOrigin = null;
let hostOrigin;
try {
const referrer = new URL(document.referrer);
if (referrer.protocol !== "http:" && referrer.protocol !== "https:") throw new Error();
hostOrigin = referrer.origin;
} catch { throw new Error("invalid MCP App sandbox parent"); }
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;
}
@@ -122,7 +127,6 @@ export function buildMcpAppSandboxProxyHtml(): string {
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 ?? {};
@@ -135,12 +139,12 @@ export function buildMcpAppSandboxProxyHtml(): string {
inner.contentWindow?.postMessage(event.data, "*");
return;
}
if (event.source === inner.contentWindow && hostOrigin !== null) {
if (event.source === inner.contentWindow) {
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: {} }, "*");
window.parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} }, hostOrigin);
})();
</script>
</body>`;
@@ -164,6 +168,7 @@ export function buildMcpAppContentSecurityPolicy(csp?: McpAppCsp): string {
`base-uri ${bases.length > 0 ? bases.join(" ") : "'self'"}`,
"object-src 'none'",
"form-action 'none'",
"frame-ancestors http: https:",
];
if (csp) {
directives.splice(5, 0, `font-src 'self' ${resources.join(" ")}`.trim());

View File

@@ -193,7 +193,7 @@ describe("MCP App UI resources", () => {
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");
expect(policy).toContain("frame-ancestors http: https:");
const sandboxPath = buildMcpAppSandboxPath(view?.csp);
const encodedCsp = new URL(sandboxPath, "https://gateway.example").searchParams.get("csp");
expect(decodeMcpAppSandboxCsp(encodedCsp)).toStrictEqual(view?.csp);
@@ -204,6 +204,8 @@ describe("MCP App UI resources", () => {
expect(proxyHtml).not.toContain("doc.write");
expect(proxyHtml).not.toContain("params.sandbox");
expect(proxyHtml).not.toContain("params.permissions");
expect(proxyHtml).toContain("document.referrer");
expect(proxyHtml).not.toContain("hostOrigin === null");
expect(proxyHtml).toContain('startsWith("ui/notifications/sandbox-")');
});
@@ -310,4 +312,39 @@ describe("MCP App UI resources", () => {
expect(getMcpAppViewLease(viewIds[0] ?? "", sessionRuntime)).toBeDefined();
expect(getMcpAppViewLease(viewIds[31] ?? "", sessionRuntime)).toBeDefined();
});
it("replaces a reconstructed view id without leaking the previous runtime lease", async () => {
const releases = [vi.fn(), vi.fn()];
const sessionRuntime = runtime(async () => ({
contents: [
{
uri: "ui://demo/app",
mimeType: MCP_APP_RESOURCE_MIME_TYPE,
text: "<html>demo</html>",
},
],
}));
sessionRuntime.acquireLease = vi
.fn()
.mockReturnValueOnce(releases[0])
.mockReturnValueOnce(releases[1]);
for (const version of [1, 2]) {
await fetchMcpAppView({
runtime: sessionRuntime,
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
viewId: "mcp-app-restored",
toolInput: { version },
toolResult: { content: [] },
});
}
expect(releases[0]).toHaveBeenCalledOnce();
expect(releases[1]).not.toHaveBeenCalled();
expect(getMcpAppViewLease("mcp-app-restored", sessionRuntime)?.toolInput).toEqual({
version: 2,
});
});
});

View File

@@ -105,6 +105,27 @@ function measureViewBytes(html: string, toolInput: unknown, toolResult: CallTool
return byteSize;
}
function assertBoundedViewDescriptor(value: {
viewId?: string;
serverName: string;
toolName: string;
uiResourceUri: string;
toolCallId?: string;
}): void {
if (
(value.viewId && (value.viewId.length > 128 || !value.viewId.startsWith("mcp-app-"))) ||
!value.serverName ||
value.serverName.length > 256 ||
!value.toolName ||
value.toolName.length > 256 ||
!value.uiResourceUri.startsWith("ui://") ||
value.uiResourceUri.length > 2_048 ||
(value.toolCallId !== undefined && value.toolCallId.length > 512)
) {
throw new Error("MCP App preview descriptor exceeds safe limits");
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -176,12 +197,25 @@ export async function fetchMcpAppView(params: {
serverName: string;
toolName: string;
uiResourceUri: string;
toolCallId?: string;
toolInput: unknown;
toolResult: CallToolResult;
allowedAppToolNames?: ReadonlySet<string>;
}): Promise<{ viewId: string; title: string } | undefined> {
viewId?: string;
}): Promise<
| {
viewId: string;
title: string;
serverName: string;
toolName: string;
uiResourceUri: string;
toolCallId?: string;
}
| undefined
> {
let releaseRuntimeLease: (() => void) | undefined;
try {
assertBoundedViewDescriptor(params);
if (!params.runtime.readResource || !params.uiResourceUri.startsWith("ui://")) {
return undefined;
}
@@ -209,8 +243,9 @@ export async function fetchMcpAppView(params: {
const csp = normalizeMcpAppCsp(uiMeta?.csp);
const permissions = normalizePermissions(uiMeta?.permissions);
const title = `${params.toolName} UI`;
const viewId = `mcp-app-${randomUUID()}`;
const viewId = params.viewId ?? `mcp-app-${randomUUID()}`;
releaseRuntimeLease = params.runtime.acquireLease?.();
deleteView(viewId);
pruneViewStore(byteSize, { reserveEntry: true });
const view: McpAppViewLease = {
viewId,
@@ -241,7 +276,14 @@ export async function fetchMcpAppView(params: {
}, MCP_APP_VIEW_TTL_MS);
view.expiryTimer.unref?.();
getViewStore().set(viewId, view);
return { viewId, title };
return {
viewId,
title,
serverName: params.serverName,
toolName: params.toolName,
uiResourceUri: params.uiResourceUri,
...(params.toolCallId ? { toolCallId: params.toolCallId } : {}),
};
} catch (error) {
releaseRuntimeLease?.();
logWarn(
@@ -290,7 +332,16 @@ export function acquireMcpAppViewRequest(
};
}
export function buildMcpAppCanvasPayload(view: { viewId: string; title: string }) {
export function buildMcpAppCanvasPayload(view: {
viewId: string;
title: string;
serverName: string;
toolName: string;
uiResourceUri: string;
toolCallId?: string;
resultMetaState?: "unavailable";
}) {
assertBoundedViewDescriptor(view);
return {
kind: "canvas",
view: { id: view.viewId, title: view.title },
@@ -300,7 +351,14 @@ export function buildMcpAppCanvasPayload(view: { viewId: string; title: string }
preferred_height: 600,
sandbox: "scripts",
},
mcpApp: { viewId: view.viewId },
mcpApp: {
viewId: view.viewId,
serverName: view.serverName,
toolName: view.toolName,
uiResourceUri: view.uiResourceUri,
...(view.toolCallId ? { toolCallId: view.toolCallId } : {}),
...(view.resultMetaState ? { resultMetaState: view.resultMetaState } : {}),
},
};
}

View File

@@ -14,10 +14,27 @@ describe("extractCanvasFromText", () => {
kind: "canvas",
view: { id: "cv_app" },
presentation: { target: "assistant_message", sandbox: "scripts" },
mcpApp: { viewId: "cv_app" },
mcpApp: {
viewId: "cv_app",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
resultMetaState: "unavailable",
},
},
}),
).toMatchObject({ viewId: "cv_app", mcpApp: { viewId: "cv_app" } });
).toMatchObject({
viewId: "cv_app",
mcpApp: {
viewId: "cv_app",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
resultMetaState: "unavailable",
},
});
});
it("keeps MCP App previews opaque while preserving model-visible results", () => {

View File

@@ -9,6 +9,15 @@ import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js";
type CanvasSurface = "assistant_message";
type CanvasSandbox = "strict" | "scripts";
export type McpAppPreviewDescriptor = {
viewId: string;
serverName?: string;
toolName?: string;
uiResourceUri?: string;
toolCallId?: string;
resultMetaState?: "unavailable";
};
type CanvasPreview = {
kind: "canvas";
surface: CanvasSurface;
@@ -20,7 +29,7 @@ type CanvasPreview = {
className?: string;
style?: string;
sandbox?: CanvasSandbox;
mcpApp?: { viewId: string };
mcpApp?: McpAppPreviewDescriptor;
};
function getRecordStringField(
@@ -47,6 +56,40 @@ function getNestedRecord(
return asOptionalRecord(value);
}
function coerceMcpAppDescriptor(
record: Record<string, unknown> | undefined,
): McpAppPreviewDescriptor | undefined {
const viewId = getRecordStringField(record, "viewId");
if (!viewId || viewId.length > 128) {
return undefined;
}
const serverName = getRecordStringField(record, "serverName");
const toolName = getRecordStringField(record, "toolName");
const uiResourceUri = getRecordStringField(record, "uiResourceUri");
const toolCallId = getRecordStringField(record, "toolCallId");
const resultMetaState = record?.resultMetaState === "unavailable" ? "unavailable" : undefined;
const hasCompleteDescriptor = Boolean(
serverName &&
serverName.length <= 256 &&
toolName &&
toolName.length <= 256 &&
uiResourceUri?.startsWith("ui://") &&
uiResourceUri.length <= 2048 &&
toolCallId &&
toolCallId.length <= 512,
);
return hasCompleteDescriptor
? {
viewId,
serverName,
toolName,
uiResourceUri,
toolCallId,
...(resultMetaState ? { resultMetaState } : {}),
}
: { viewId };
}
function normalizeSurface(value: string | undefined): CanvasSurface | undefined {
return value === "assistant_message" ? value : undefined;
}
@@ -75,7 +118,8 @@ function coerceCanvasPreview(
const view = getNestedRecord(record, "view");
const source = getNestedRecord(record, "source");
const mcpAppRecord = getNestedRecord(record, "mcpApp");
const mcpAppViewId = getRecordStringField(mcpAppRecord, "viewId");
const mcpApp = coerceMcpAppDescriptor(mcpAppRecord);
const mcpAppViewId = mcpApp?.viewId;
const requestedSurface =
getRecordStringField(presentation, "target") ?? getRecordStringField(record, "target");
const surface = requestedSurface ? normalizeSurface(requestedSurface) : "assistant_message";
@@ -105,7 +149,7 @@ function coerceCanvasPreview(
...(title ? { title } : {}),
...(preferredHeight ? { preferredHeight } : {}),
...(sandbox ? { sandbox } : {}),
mcpApp: { viewId: mcpAppViewId },
mcpApp,
};
}
if (viewUrl) {
@@ -120,7 +164,7 @@ function coerceCanvasPreview(
...(className ? { className } : {}),
...(style ? { style } : {}),
...(sandbox ? { sandbox } : {}),
...(mcpAppViewId ? { mcpApp: { viewId: mcpAppViewId } } : {}),
...(mcpApp ? { mcpApp } : {}),
};
}
const sourceType = getRecordStringField(source, "type")?.trim().toLowerCase();
@@ -139,7 +183,7 @@ function coerceCanvasPreview(
...(className ? { className } : {}),
...(style ? { style } : {}),
...(sandbox ? { sandbox } : {}),
...(mcpAppViewId ? { mcpApp: { viewId: mcpAppViewId } } : {}),
...(mcpApp ? { mcpApp } : {}),
};
}
return undefined;

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from "vitest";
import {
findMcpAppReconstructionData,
findMcpAppReconstructionDataByVisit,
} from "./mcp-app-reconstruction.js";
describe("MCP App transcript reconstruction", () => {
it("reconstructs only a descriptor bound to its tool call and result", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "call-1",
name: "demo__show",
arguments: { city: "Paris" },
},
],
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "demo__show",
content: [
{ type: "text", text: "ok" },
{ type: "audio", data: "YXVkaW8=", mimeType: "audio/mpeg" },
],
details: {
mcpServer: "demo",
mcpTool: "show",
structuredContent: { city: "Paris" },
mcpAppPreview: {
kind: "canvas",
view: { id: "mcp-app-1" },
mcpApp: {
viewId: "mcp-app-1",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
},
},
},
},
];
expect(findMcpAppReconstructionData(messages, "mcp-app-1")).toEqual({
descriptor: {
viewId: "mcp-app-1",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
},
toolInput: { city: "Paris" },
toolResult: {
content: [
{ type: "text", text: "ok" },
{ type: "audio", data: "YXVkaW8=", mimeType: "audio/mpeg" },
],
structuredContent: { city: "Paris" },
},
});
});
it("rejects client-selected descriptors that do not match transcript ownership", () => {
expect(
findMcpAppReconstructionData(
[
{
role: "toolResult",
toolCallId: "call-other",
toolName: "demo__show",
details: {
mcpServer: "demo",
mcpTool: "show",
mcpAppPreview: {
mcpApp: {
viewId: "mcp-app-1",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
},
},
},
},
],
"mcp-app-1",
),
).toBeUndefined();
});
it("binds reused call IDs to the nearest preceding matching tool", () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "shared", name: "other__tool", args: { secret: 1 } }],
},
{
role: "assistant",
content: [{ type: "toolCall", id: "shared", name: "demo__show", args: { page: 2 } }],
},
{
role: "toolResult",
toolCallId: "shared",
toolName: "demo__show",
content: [],
details: {
mcpServer: "demo",
mcpTool: "show",
mcpAppPreview: {
mcpApp: {
viewId: "mcp-app-reused",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "shared",
},
},
},
},
{
role: "assistant",
content: [{ type: "toolCall", id: "shared", name: "demo__show", args: { page: 3 } }],
},
];
expect(findMcpAppReconstructionData(messages, "mcp-app-reused")?.toolInput).toEqual({
page: 2,
});
});
it("declines restart reconstruction when app-only result metadata was not persisted", () => {
expect(
findMcpAppReconstructionData(
[
{
role: "assistant",
content: [{ type: "toolCall", id: "call-old", name: "demo__show", args: {} }],
},
{
role: "toolResult",
toolCallId: "call-old",
toolName: "demo__show",
content: [{ type: "text", text: "stale" }],
details: {
mcpServer: "demo",
mcpTool: "show",
mcpAppPreview: {
mcpApp: {
viewId: "mcp-app-meta",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-old",
},
},
},
},
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "demo__show", args: {} }],
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "demo__show",
content: [],
details: {
mcpServer: "demo",
mcpTool: "show",
mcpAppPreview: {
mcpApp: {
viewId: "mcp-app-meta",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
resultMetaState: "unavailable",
},
},
},
},
],
"mcp-app-meta",
),
).toBeUndefined();
});
it("rejects a descriptor without its matching tool-call input", () => {
expect(
findMcpAppReconstructionData(
[
{
role: "toolResult",
toolCallId: "call-1",
toolName: "demo__show",
content: [],
details: {
mcpServer: "demo",
mcpTool: "show",
mcpAppPreview: {
mcpApp: {
viewId: "mcp-app-1",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
},
},
},
},
],
"mcp-app-1",
),
).toBeUndefined();
});
it("streams the full active transcript instead of limiting reconstruction to its tail", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "demo__show", args: { page: 1 } }],
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "demo__show",
content: [{ type: "text", text: "ok" }],
details: {
mcpServer: "demo",
mcpTool: "show",
mcpAppPreview: {
mcpApp: {
viewId: "mcp-app-1",
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app",
toolCallId: "call-1",
},
},
},
},
...Array.from({ length: 2_500 }, (_, index) => ({
role: "assistant",
content: [{ type: "text", text: `later-${index}` }],
})),
];
let passes = 0;
const result = await findMcpAppReconstructionDataByVisit(async (visit) => {
passes += 1;
for (const message of messages) {
visit(message);
}
}, "mcp-app-1");
expect(passes).toBe(2);
expect(result?.toolInput).toEqual({ page: 1 });
});
});

View File

@@ -0,0 +1,337 @@
import { type CallToolResult, ContentBlockSchema } from "@modelcontextprotocol/sdk/types.js";
import { getOrCreateSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js";
import type { SessionMcpRuntime } from "../agents/agent-bundle-mcp-types.js";
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
fetchMcpAppView,
getMcpAppViewLease,
type McpAppViewLease,
} from "../agents/mcp-ui-resource.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { loadSessionEntry, visitSessionMessagesAsync } from "./session-utils.js";
const MCP_APP_RESTORE_IN_FLIGHT_KEY = Symbol.for("openclaw.mcpAppRestoreInFlight");
type McpAppDescriptor = {
viewId: string;
serverName: string;
toolName: string;
uiResourceUri: string;
toolCallId: string;
resultMetaState?: "unavailable";
};
type ReconstructionData = {
descriptor: McpAppDescriptor;
toolInput: unknown;
toolResult: CallToolResult;
};
type ReconstructionResult = {
runtime: SessionMcpRuntime;
view: McpAppViewLease;
};
type TranscriptVisit = (visit: (message: unknown) => void) => Promise<void>;
type TranscriptResult = Omit<ReconstructionData, "toolInput"> & { modelToolName: string };
type TranscriptResultRead =
| { kind: "restorable"; value: TranscriptResult }
| { kind: "unavailable" };
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readDescriptor(value: unknown): McpAppDescriptor | undefined {
const record = asRecord(value);
const viewId = readString(record, "viewId");
const serverName = readString(record, "serverName");
const toolName = readString(record, "toolName");
const uiResourceUri = readString(record, "uiResourceUri");
const toolCallId = readString(record, "toolCallId");
const rawResultMetaState = record?.resultMetaState;
const resultMetaState = rawResultMetaState === "unavailable" ? rawResultMetaState : undefined;
if (
!viewId ||
viewId.length > 128 ||
!serverName ||
serverName.length > 256 ||
!toolName ||
toolName.length > 256 ||
!uiResourceUri?.startsWith("ui://") ||
uiResourceUri.length > 2048 ||
!toolCallId ||
toolCallId.length > 512 ||
(rawResultMetaState !== undefined && resultMetaState === undefined)
) {
return undefined;
}
return {
viewId,
serverName,
toolName,
uiResourceUri,
toolCallId,
...(resultMetaState ? { resultMetaState } : {}),
};
}
function readToolInputFromMessage(
value: unknown,
toolCallId: string,
modelToolName: string,
): { found: true; input: unknown } | undefined {
const message = asRecord(value);
if (readString(message, "role")?.toLowerCase() !== "assistant") {
return undefined;
}
const content = Array.isArray(message?.content) ? message.content : [];
for (const blockValue of content) {
const block = asRecord(blockValue);
if ((readString(block, "id") ?? readString(block, "toolCallId")) !== toolCallId) {
continue;
}
const type = readString(block, "type")?.toLowerCase();
if (type !== "toolcall" && type !== "tool_call" && type !== "tooluse" && type !== "tool_use") {
continue;
}
const blockToolName =
readString(block, "name") ?? readString(block, "toolName") ?? readString(block, "tool_name");
if (blockToolName !== modelToolName) {
continue;
}
return { found: true, input: block?.arguments ?? block?.input ?? block?.args ?? {} };
}
return undefined;
}
function readCallToolResult(message: Record<string, unknown>, details: Record<string, unknown>) {
const content = Array.isArray(message.content)
? message.content.flatMap((value) => {
const parsed = ContentBlockSchema.safeParse(value);
return parsed.success ? [parsed.data] : [];
})
: [];
return {
content,
...(details.structuredContent !== undefined
? { structuredContent: details.structuredContent }
: {}),
...(message.isError === true || details.status === "error" ? { isError: true } : {}),
} as CallToolResult;
}
function readTranscriptResult(value: unknown, viewId: string): TranscriptResultRead | undefined {
const message = asRecord(value);
if (!message || readString(message, "role")?.toLowerCase() !== "toolresult") {
return undefined;
}
const details = asRecord(message.details);
if (!details) {
return undefined;
}
const preview = asRecord(details.mcpAppPreview);
const rawDescriptor = asRecord(preview?.mcpApp);
if (readString(rawDescriptor, "viewId") !== viewId) {
return undefined;
}
const descriptor = readDescriptor(rawDescriptor);
const modelToolName = readString(message, "toolName") ?? readString(message, "tool_name");
if (!descriptor || !modelToolName) {
return { kind: "unavailable" };
}
if (
readString(message, "toolCallId") !== descriptor.toolCallId ||
readString(details, "mcpServer") !== descriptor.serverName ||
readString(details, "mcpTool") !== descriptor.toolName ||
descriptor.resultMetaState === "unavailable"
) {
return { kind: "unavailable" };
}
return {
kind: "restorable",
value: { descriptor, modelToolName, toolResult: readCallToolResult(message, details) },
};
}
/** Finds a server-authored descriptor and its canonical tool call/result pair. */
export function findMcpAppReconstructionData(
messages: unknown[],
viewId: string,
): ReconstructionData | undefined {
for (let resultIndex = messages.length - 1; resultIndex >= 0; resultIndex -= 1) {
const read = readTranscriptResult(messages[resultIndex], viewId);
if (!read) {
continue;
}
if (read.kind === "unavailable") {
return undefined;
}
const result = read.value;
let input: { found: true; input: unknown } | undefined;
for (let inputIndex = resultIndex - 1; inputIndex >= 0; inputIndex -= 1) {
input = readToolInputFromMessage(
messages[inputIndex],
result.descriptor.toolCallId,
result.modelToolName,
);
if (input) {
break;
}
}
if (!input) {
return undefined;
}
const { modelToolName: _modelToolName, ...reconstruction } = result;
return {
...reconstruction,
toolInput: input.input,
};
}
return undefined;
}
/** Searches the full active transcript without retaining its messages in memory. */
export async function findMcpAppReconstructionDataByVisit(
visitTranscript: TranscriptVisit,
viewId: string,
): Promise<ReconstructionData | undefined> {
let resultRead: TranscriptResultRead | undefined;
let resultIndex = -1;
let messageIndex = 0;
await visitTranscript((message) => {
const read = readTranscriptResult(message, viewId);
if (read) {
resultRead = read;
resultIndex = messageIndex;
}
messageIndex += 1;
});
if (!resultRead || resultRead.kind === "unavailable") {
return undefined;
}
const resolvedResult = resultRead.value;
let toolInput: unknown;
let foundInput = false;
messageIndex = 0;
await visitTranscript((message) => {
if (messageIndex >= resultIndex) {
messageIndex += 1;
return;
}
const input = readToolInputFromMessage(
message,
resolvedResult.descriptor.toolCallId,
resolvedResult.modelToolName,
);
if (input) {
foundInput = true;
toolInput = input.input;
}
messageIndex += 1;
});
if (!foundInput) {
return undefined;
}
const { modelToolName: _modelToolName, ...reconstruction } = resolvedResult;
return { ...reconstruction, toolInput };
}
function getRestoreInFlight(): Map<string, Promise<ReconstructionResult | undefined>> {
const state = globalThis as Record<PropertyKey, unknown>;
const existing = state[MCP_APP_RESTORE_IN_FLIGHT_KEY] as
| Map<string, Promise<ReconstructionResult | undefined>>
| undefined;
if (existing) {
return existing;
}
const created = new Map<string, Promise<ReconstructionResult | undefined>>();
state[MCP_APP_RESTORE_IN_FLIGHT_KEY] = created;
return created;
}
async function restoreMcpAppViewOnce(params: {
cfg: OpenClawConfig;
sessionKey: string;
viewId: string;
}): Promise<ReconstructionResult | undefined> {
if (!params.viewId.startsWith("mcp-app-") || params.viewId.length > 128) {
return undefined;
}
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const loaded = loadSessionEntry(params.sessionKey, { agentId });
const sessionId = loaded.entry?.sessionId;
if (!sessionId) {
return undefined;
}
const transcriptScope = {
agentId,
sessionId,
sessionKey: loaded.canonicalKey,
storePath: loaded.storePath,
sessionEntry: loaded.entry,
};
const data = await findMcpAppReconstructionDataByVisit(async (visit) => {
await visitSessionMessagesAsync(transcriptScope, (message) => visit(message), {
mode: "full",
reason: "MCP App restart reconstruction",
cache: "reuse",
});
}, params.viewId);
if (!data) {
return undefined;
}
const runtime = await getOrCreateSessionMcpRuntime({
sessionId,
sessionKey: loaded.canonicalKey,
workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId),
agentDir: resolveAgentDir(params.cfg, agentId),
cfg: params.cfg,
});
if (runtime.mcpAppsEnabled !== true) {
return undefined;
}
await fetchMcpAppView({
runtime,
serverName: data.descriptor.serverName,
toolName: data.descriptor.toolName,
uiResourceUri: data.descriptor.uiResourceUri,
toolCallId: data.descriptor.toolCallId,
toolInput: data.toolInput,
toolResult: data.toolResult,
viewId: data.descriptor.viewId,
// A reconstructed preview can render and read its owning server resources,
// but cannot call tools without a fresh run carrying current effective policy.
allowedAppToolNames: new Set(),
});
const view = getMcpAppViewLease(params.viewId, runtime);
return view ? { runtime, view } : undefined;
}
export async function restoreMcpAppView(params: {
cfg: OpenClawConfig;
sessionKey: string;
viewId: string;
}): Promise<ReconstructionResult | undefined> {
const key = `${params.sessionKey}\0${params.viewId}`;
const inFlight = getRestoreInFlight();
const existing = inFlight.get(key);
if (existing) {
return await existing;
}
const pending = restoreMcpAppViewOnce(params).finally(() => {
if (inFlight.get(key) === pending) {
inFlight.delete(key);
}
});
inFlight.set(key, pending);
return await pending;
}

View File

@@ -26,15 +26,13 @@ describe("MCP App sandbox HTTP origin", () => {
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(String(csp)).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"),
);
expect(result.end).toHaveBeenCalledWith(expect.stringContaining("document.referrer"));
});
it("supports HEAD and rejects other paths, methods, and malformed policy", () => {

View File

@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
completeDeferredSessionMcpRuntimeRetirement: vi.fn(),
getMcpAppViewLease: vi.fn(),
peekSessionMcpRuntime: vi.fn(),
restoreMcpAppView: vi.fn(),
}));
vi.mock("../../agents/mcp-ui-resource.js", () => ({
@@ -18,6 +19,9 @@ vi.mock("../../agents/agent-bundle-mcp-runtime.js", () => ({
completeDeferredSessionMcpRuntimeRetirement: mocks.completeDeferredSessionMcpRuntimeRetirement,
peekSessionMcpRuntime: mocks.peekSessionMcpRuntime,
}));
vi.mock("../mcp-app-reconstruction.js", () => ({
restoreMcpAppView: mocks.restoreMcpAppView,
}));
import { mcpAppHandlers } from "./mcp-app.js";
@@ -73,7 +77,11 @@ function runtime() {
};
}
async function invoke(method: keyof typeof mcpAppHandlers, params: Record<string, unknown>) {
async function invoke(
method: keyof typeof mcpAppHandlers,
params: Record<string, unknown>,
mcpAppsEnabled = true,
) {
const respond = vi.fn();
await expectDefined(
mcpAppHandlers[method],
@@ -84,7 +92,7 @@ async function invoke(method: keyof typeof mcpAppHandlers, params: Record<string
context: {
getMcpAppSandboxPort: () => 18790,
getRuntimeConfig: () => ({
mcp: { apps: { enabled: true, sandboxOrigin: "https://apps.example.com" } },
mcp: { apps: { enabled: mcpAppsEnabled, sandboxOrigin: "https://apps.example.com" } },
}),
},
} as never);
@@ -100,6 +108,7 @@ describe("MCP App gateway bridge", () => {
mocks.getMcpAppViewLease.mockReset().mockReturnValue(view);
mocks.completeDeferredSessionMcpRuntimeRetirement.mockReset().mockResolvedValue(false);
mocks.peekSessionMcpRuntime.mockReset().mockReturnValue(runtime());
mocks.restoreMcpAppView.mockReset().mockResolvedValue(undefined);
});
it("returns the ephemeral view payload only for the bound session", async () => {
@@ -165,12 +174,49 @@ describe("MCP App gateway bridge", () => {
expect(denied.mock.calls[0]?.[0]).toBe(false);
});
it("never creates a runtime for an expired view", async () => {
it("rejects views that are not backed by the transcript", 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);
expect(mocks.restoreMcpAppView).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
viewId: "expired",
}),
);
});
it("rejects disabled Apps before attempting transcript reconstruction", async () => {
mocks.peekSessionMcpRuntime.mockReturnValue(undefined);
const respond = await invoke(
"mcp.app.view",
{ sessionKey: "agent:main:main", viewId: "cv_app" },
false,
);
expect(respond.mock.calls[0]?.[0]).toBe(false);
expect(mocks.restoreMcpAppView).not.toHaveBeenCalled();
});
it("restores a transcript-backed view after a Gateway restart", async () => {
const restoredRuntime = runtime();
const restoredView = { ...view, runtime: restoredRuntime, allowedAppToolNames: new Set() };
mocks.peekSessionMcpRuntime.mockReturnValue(undefined);
mocks.restoreMcpAppView.mockResolvedValue({
runtime: restoredRuntime,
view: restoredView,
});
const respond = await invoke("mcp.app.view", {
sessionKey: "agent:main:main",
viewId: "cv_app",
});
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(respond.mock.calls[0]?.[1]).toMatchObject({ html: "<html>demo</html>" });
});
});

View File

@@ -11,8 +11,10 @@ import {
getMcpAppViewLease,
type McpAppViewLease,
} from "../../agents/mcp-ui-resource.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { logWarn } from "../../logger.js";
import { restoreMcpAppView } from "../mcp-app-reconstruction.js";
import type { GatewayRequestHandlers } from "./types.js";
function requireString(params: Record<string, unknown>, key: string): string {
@@ -50,20 +52,33 @@ function isAllowedByView(view: McpAppViewLease, toolName: string): boolean {
return view.allowedAppToolNames === undefined || view.allowedAppToolNames.has(toolName);
}
function requireActiveView(params: Record<string, unknown>): {
async function requireActiveView(
params: Record<string, unknown>,
cfg?: OpenClawConfig,
): Promise<{
runtime: SessionMcpRuntime;
view: McpAppViewLease;
} {
}> {
const sessionKey = requireString(params, "sessionKey");
const viewId = requireString(params, "viewId");
const runtime = peekSessionMcpRuntime({ sessionKey });
if (!runtime || runtime.mcpAppsEnabled !== true) {
const existingRuntime = peekSessionMcpRuntime({ sessionKey });
if (
(existingRuntime && existingRuntime.mcpAppsEnabled !== true) ||
(cfg && cfg.mcp?.apps?.enabled !== true)
) {
throw new Error("MCP App runtime is unavailable");
}
const view = getMcpAppViewLease(viewId, runtime);
if (!view) {
const existingView = existingRuntime ? getMcpAppViewLease(viewId, existingRuntime) : undefined;
const restored =
existingRuntime?.mcpAppsEnabled === true && existingView
? { runtime: existingRuntime, view: existingView }
: cfg
? await restoreMcpAppView({ cfg, sessionKey, viewId })
: undefined;
if (!restored) {
throw new Error("MCP App view expired or is not authorized for this session");
}
const { runtime, view } = restored;
runtime.markUsed();
return { runtime, view };
}
@@ -72,8 +87,9 @@ async function withActiveView<T>(
params: Record<string, unknown>,
kind: "read" | "tool",
operation: (active: { runtime: SessionMcpRuntime; view: McpAppViewLease }) => Promise<T> | T,
cfg?: OpenClawConfig,
): Promise<T> {
const active = requireActiveView(params);
const active = await requireActiveView(params, cfg);
const release = acquireMcpAppViewRequest(active.view, kind);
const releaseRuntimeLease = active.runtime.acquireLease?.();
try {
@@ -120,22 +136,27 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
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,
};
}),
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,
};
},
context.getRuntimeConfig(),
),
);
},
"mcp.app.callTool": async ({ respond, params }) => {