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

@@ -872,9 +872,9 @@ 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.
- App-only tools (`_meta.ui.visibility: ["app"]`) stay out of model tool lists. Apps can call only app-visible tools on their owning server that also pass the effective OpenClaw tool policy for the run that created the view.
- 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.
- App HTML, complete tool arguments, and raw results live in a bounded ten-minute in-memory view lease and are not written to disk or copied into transcript preview metadata. The transcript stores only a bounded server/tool/resource descriptor tied to the original tool-call ID. After a Gateway restart, the Control UI can verify that descriptor against the authenticated session transcript and refetch the `ui://` resource; reconstructed views are read-only until a fresh run establishes current tool permissions.
- `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

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 }) => {

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { i18n } from "../i18n/index.ts";
import { McpAppView } from "./mcp-app-view.ts";
import { McpAppView, waitForMcpAppHandlerRegistration } from "./mcp-app-view.ts";
const MCP_APP_VIEW_ELEMENT_NAME = `test-mcp-app-view-${crypto.randomUUID()}`;
@@ -34,4 +34,44 @@ describe("mcp-app-view localization", () => {
.poll(() => view.shadowRoot?.querySelector(".error")?.textContent)
.toBe("Aplicativo MCP indisponível: MCP App gateway unavailable");
});
it("crosses two paint boundaries before delivering initial tool notifications", async () => {
const frames: FrameRequestCallback[] = [];
const pending = waitForMcpAppHandlerRegistration((callback) => {
frames.push(callback);
return frames.length;
});
let resolved = false;
void pending.then(() => {
resolved = true;
});
frames.shift()?.(0);
await Promise.resolve();
expect(resolved).toBe(false);
frames.shift()?.(16);
await pending;
expect(resolved).toBe(true);
});
it("uses a delayed fallback when animation frames are suspended", async () => {
const frames: FrameRequestCallback[] = [];
let fallback: (() => void) | undefined;
const pending = waitForMcpAppHandlerRegistration(
(callback) => {
frames.push(callback);
return frames.length;
},
(callback, delayMs) => {
expect(delayMs).toBe(1_000);
fallback = callback;
return 1;
},
);
expect(frames).toHaveLength(1);
fallback?.();
await pending;
});
});

View File

@@ -29,6 +29,25 @@ type HostContext = NonNullable<
type HostCapabilities = ConstructorParameters<typeof AppBridge>[2];
type HostSandboxCsp = NonNullable<NonNullable<HostCapabilities["sandbox"]>["csp"]>;
type ScheduleFrame = (callback: FrameRequestCallback) => number;
type ScheduleFallback = (callback: () => void, delayMs: number) => number;
export async function waitForMcpAppHandlerRegistration(
scheduleFrame: ScheduleFrame = window.requestAnimationFrame.bind(window),
scheduleFallback: ScheduleFallback = window.setTimeout.bind(window),
): Promise<void> {
await Promise.race([
new Promise<void>((resolve) => {
scheduleFrame(() => {
scheduleFrame(() => resolve());
});
}),
new Promise<void>((resolve) => {
scheduleFallback(resolve, 1_000);
}),
]);
}
function hostContext(element: Element | undefined, height: number): HostContext {
const rect = element?.getBoundingClientRect();
const touch = navigator.maxTouchPoints > 0 || window.matchMedia?.("(pointer: coarse)").matches;
@@ -224,7 +243,9 @@ export class McpAppView extends LitElement {
}
const iframe = document.createElement("iframe");
iframe.title = this.title || t("mcpApp.title");
iframe.referrerPolicy = "no-referrer";
// The isolated proxy binds its parent before accepting messages. Only the
// Control UI origin is disclosed; path/query data remains suppressed.
iframe.referrerPolicy = "origin";
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.
@@ -315,6 +336,10 @@ export class McpAppView extends LitElement {
window.setTimeout(() => reject(new Error("MCP App initialization timed out")), 15_000);
}),
]);
await waitForMcpAppHandlerRegistration();
if (generation !== this.setupGeneration) {
return;
}
await bridge.sendToolInput({
arguments:
payload.toolInput &&

View File

@@ -163,7 +163,13 @@ export type ToolCard = {
className?: string;
style?: string;
sandbox?: "strict" | "scripts";
mcpApp?: { viewId: string };
mcpApp?: {
viewId: string;
serverName?: string;
toolName?: string;
uiResourceUri?: string;
toolCallId?: string;
};
};
};

View File

@@ -105,7 +105,17 @@ function coerceCanvasPreview(
? { sandbox: preview.sandbox }
: {}),
...(typeof mcpApp?.viewId === "string" && mcpApp.viewId.trim()
? { mcpApp: { viewId: mcpApp.viewId } }
? {
mcpApp: {
viewId: mcpApp.viewId,
...(typeof mcpApp.serverName === "string" ? { serverName: mcpApp.serverName } : {}),
...(typeof mcpApp.toolName === "string" ? { toolName: mcpApp.toolName } : {}),
...(typeof mcpApp.uiResourceUri === "string"
? { uiResourceUri: mcpApp.uiResourceUri }
: {}),
...(typeof mcpApp.toolCallId === "string" ? { toolCallId: mcpApp.toolCallId } : {}),
},
}
: {}),
};
}

View File

@@ -1803,6 +1803,38 @@ describe("buildChatItems", () => {
expect(canvasBlocksIn(groupAt(groups, 1))).toStrictEqual([]);
});
it("keeps a live App preview on an assistant search match", () => {
const groups = messageGroups({
messages: [{ role: "assistant", content: "Matching preview", timestamp: 1_000 }],
toolMessages: [mcpAppResult("mcp-app-search", "call-search", 1_001)],
searchOpen: true,
searchQuery: "matching",
showToolCalls: false,
});
expect(canvasBlocksIn(groupAt(groups, 0))).toHaveLength(1);
});
it("keeps a persisted App preview on an assistant search match", () => {
for (const showToolCalls of [false, true]) {
const groups = messageGroups({
messages: [
{ role: "user", content: "Show the App", timestamp: 1_000 },
mcpAppResult("mcp-app-persisted-search", "call-persisted-search", 1_001),
{ role: "assistant", content: "Matching preview", timestamp: 1_002 },
],
toolMessages: [],
searchOpen: true,
searchQuery: "matching",
showToolCalls,
});
const assistant = groups.find((group) => group.role === "assistant");
expect(assistant).toBeDefined();
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
}
});
it("preserves a metadata-only assistant anchor when lifting canvas previews", () => {
const groups = messageGroups({
messages: [
@@ -1842,6 +1874,184 @@ describe("buildChatItems", () => {
).toBe(true);
});
it("creates an assistant anchor for a silent App turn", () => {
const groups = messageGroups({
messages: [
{ role: "user", content: "First request", timestamp: 1_000 },
{ role: "assistant", content: "First response", timestamp: 1_001 },
{ role: "user", content: "Show the App", timestamp: 2_000 },
],
toolMessages: [mcpAppResult("mcp-app-silent", "call-silent", 2_001)],
queue: [
{
id: "queued-next-turn",
text: "Next request",
createdAt: 2_100,
sendSubmittedAtMs: 2_100,
sendState: "sending",
},
],
showToolCalls: false,
});
const assistants = groups.filter((group) => group.role === "assistant");
expect(assistants).toHaveLength(2);
expect(canvasBlocksIn(groupAt(assistants, 0))).toStrictEqual([]);
expect(canvasBlocksIn(groupAt(assistants, 1))).toHaveLength(1);
});
it("keeps an earlier silent App preview before the next user turn", () => {
const groups = messageGroups({
messages: [
{ role: "user", content: "Show the App", timestamp: 1_000 },
{ role: "user", content: "Next request", timestamp: 2_000 },
],
toolMessages: [mcpAppResult("mcp-app-earlier", "call-earlier", 1_001)],
showToolCalls: false,
});
expect(groups.map((group) => group.role)).toEqual(["user", "assistant", "user"]);
expect(canvasBlocksIn(groupAt(groups, 1))).toHaveLength(1);
});
it("places an App preview after its queued user prompt", () => {
const groups = messageGroups({
messages: [
{ role: "user", content: "First request", timestamp: 1_000 },
{ role: "assistant", content: "First response", timestamp: 1_001 },
],
queue: [
{
id: "queued-app-turn",
text: "Show the App",
createdAt: 2_000,
sendSubmittedAtMs: 2_000,
sendState: "waiting-model",
},
{
id: "queued-future-turn",
text: "Later request",
createdAt: 2_001,
sendSubmittedAtMs: 2_001,
sendState: "waiting-reconnect",
},
],
toolMessages: [mcpAppResult("mcp-app-queued", "call-queued", 2_002)],
showToolCalls: false,
});
expect(groups.map((group) => group.role)).toEqual([
"user",
"assistant",
"user",
"assistant",
"user",
]);
expect(canvasBlocksIn(groupAt(groups, 3))).toHaveLength(1);
});
it("restores a persisted App preview without the live tool cache", () => {
for (const showToolCalls of [false, true]) {
const groups = messageGroups({
messages: [
{ role: "user", content: "Show the App", timestamp: 1_000 },
mcpAppResult("mcp-app-persisted", "call-persisted", 1_001),
],
toolMessages: [],
showToolCalls,
});
const assistant = groups.find((group) => group.role === "assistant");
expect(assistant).toBeDefined();
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
}
});
it("deduplicates persisted and live copies of an App preview", () => {
const result = mcpAppResult("mcp-app-overlap", "call-overlap", 1_001);
const groups = messageGroups({
messages: [{ role: "user", content: "Show the App", timestamp: 1_000 }, result],
toolMessages: [result],
showToolCalls: false,
});
const assistant = groups.find((group) => group.role === "assistant");
expect(assistant).toBeDefined();
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
});
it("deduplicates timestamp-less persisted and live copies in the same turn", () => {
const persisted = {
...mcpAppResult("mcp-app-untimestamped", "call-untimestamped", 1_001),
timestamp: undefined,
};
const groups = messageGroups({
messages: [{ role: "user", content: "Show the App", timestamp: 1_000 }, persisted],
toolMessages: [mcpAppLiveResult("mcp-app-untimestamped", "call-untimestamped", 1_001)],
showToolCalls: false,
});
expect(groups.flatMap((group) => canvasBlocksIn(group))).toHaveLength(1);
});
it("keeps distinct App previews when a tool-call ID is reused", () => {
const first = {
...mcpAppResult("mcp-app-first", "call-reused", 1_001),
timestamp: undefined,
};
const second = mcpAppLiveResult("mcp-app-second", "call-reused", undefined);
const groups = messageGroups({
messages: [
{ role: "user", content: "First App", timestamp: 1_000 },
first,
{ role: "user", content: "Second App", timestamp: 2_000 },
],
toolMessages: [second],
showToolCalls: false,
});
expect(groups.map((group) => group.role)).toEqual(["user", "assistant", "user", "assistant"]);
expect(canvasBlocksIn(groupAt(groups, 1))).toHaveLength(1);
expect(canvasBlocksIn(groupAt(groups, 3))).toHaveLength(1);
});
it("keeps a persisted App preview with its history-cutoff assistant", () => {
const groups = messageGroups({
messages: [
{ role: "user", content: "Show the App", timestamp: 1_000 },
mcpAppResult("mcp-app-cutoff", "call-cutoff", 1_001),
{ role: "assistant", content: "Done", timestamp: 1_002 },
],
toolMessages: [],
showToolCalls: false,
historyRenderLimit: 1,
});
const assistant = groups.find((group) => group.role === "assistant");
expect(assistant).toBeDefined();
expect(canvasBlocksIn(assistant as MessageGroup)).toHaveLength(1);
});
it("does not expand a persisted preview across a cutoff user turn", () => {
const firstResult = {
...mcpAppResult("mcp-app-first", "call-first", 1_001),
timestamp: undefined,
};
const groups = messageGroups({
messages: [
{ role: "user", content: "First request", timestamp: 1_000 },
firstResult,
{ role: "user", content: "Second request", timestamp: 2_000 },
{ role: "assistant", content: "Second response", timestamp: 2_001 },
],
toolMessages: [{ ...firstResult }],
showToolCalls: false,
historyRenderLimit: 2,
});
expect(groups.flatMap((group) => canvasBlocksIn(group))).toStrictEqual([]);
});
it("does not lift generic view handles from non-canvas payloads", () => {
const groups = messageGroups({
messages: [
@@ -2069,6 +2279,51 @@ function createAssistantCanvasBlock(params: { suffix: string }) {
};
}
function mcpAppResult(viewId: string, toolCallId: string, timestamp: number) {
return {
role: "toolResult",
toolCallId,
toolName: "demo__show",
content: [{ type: "text", text: "ok" }],
details: {
mcpAppPreview: {
kind: "canvas",
view: { id: viewId, title: "Demo App" },
presentation: { target: "assistant_message", sandbox: "scripts" },
mcpApp: {
viewId,
serverName: "demo",
toolName: "show",
uiResourceUri: "ui://demo/app.html",
toolCallId,
},
},
},
timestamp,
};
}
function mcpAppLiveResult(viewId: string, toolCallId: string, timestamp: number | undefined) {
const persisted = mcpAppResult(viewId, toolCallId, timestamp ?? 0);
return {
role: "assistant",
toolCallId,
runId: "run-live",
content: [
{ type: "toolcall", name: "demo__show", arguments: {} },
{
type: "toolresult",
name: "demo__show",
text: "ok",
details: persisted.details,
},
],
...(timestamp == null ? {} : { timestamp }),
__openclawToolStreamLive: true,
__openclawToolStreamResultReceived: true,
};
}
describe("tool turn outcome annotation (#89683)", () => {
function failedTool(timestamp: number) {
return {

View File

@@ -157,13 +157,36 @@ function messageMatchesSearchQuery(message: unknown, query: string): boolean {
return text.includes(normalizedQuery);
}
function extractChatMessagePreview(toolMessage: unknown): {
function turnHasMatchingAssistant(
messages: unknown[],
sourceIndex: number,
searchQuery: string,
): boolean {
for (let index = sourceIndex + 1; index < messages.length; index += 1) {
const message = messages[index];
const normalized = safeNormalizeMessage(message);
if (!normalized) {
continue;
}
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
if (role === "user" || role === "system") {
return false;
}
if (role === "assistant" && messageMatchesSearchQuery(message, searchQuery)) {
return true;
}
}
return false;
}
type ChatMessagePreview = {
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
text: string | null;
timestamp: number | null;
} | null {
const normalized = safeNormalizeMessage(toolMessage);
if (!normalized) {
};
function extractChatMessagePreview(toolMessage: unknown): ChatMessagePreview | null {
if (!safeNormalizeMessage(toolMessage)) {
return null;
}
const cards = extractToolCardsCached(toolMessage, "preview");
@@ -173,7 +196,7 @@ function extractChatMessagePreview(toolMessage: unknown): {
return {
preview: card.preview,
text: card.outputText ?? null,
timestamp: normalized.timestamp ?? null,
timestamp: rawMessageTimestamp(toolMessage),
};
}
}
@@ -189,16 +212,88 @@ function extractChatMessagePreview(toolMessage: unknown): {
if (preview?.kind !== "canvas") {
return null;
}
return { preview, text: text ?? null, timestamp: normalized.timestamp ?? null };
return { preview, text: text ?? null, timestamp: rawMessageTimestamp(toolMessage) };
}
function canvasPreviewBaseIdentity(message: unknown, source: ChatMessagePreview): string | null {
const toolCallId = resolveMessageToolUseId(asRecord(message) ?? {});
const previewId = source.preview.viewId ?? source.preview.url;
return toolCallId && previewId ? JSON.stringify([toolCallId, previewId]) : null;
}
function createCanvasAssistantMessage(
source: ChatMessagePreview,
timestamp = source.timestamp,
): unknown {
return appendCanvasBlockToAssistantMessage(
{
role: "assistant",
content: [],
...(timestamp != null ? { timestamp } : {}),
},
source.preview,
source.text,
);
}
function transcriptPositionTimestamp(messages: unknown[], sourceIndex: number): number | null {
let previous: number | null = null;
for (let index = sourceIndex - 1; index >= 0; index -= 1) {
previous = rawMessageTimestamp(messages[index]);
if (previous != null) {
break;
}
}
let next: number | null = null;
for (let index = sourceIndex + 1; index < messages.length; index += 1) {
next = rawMessageTimestamp(messages[index]);
if (next != null) {
break;
}
}
if (previous != null && next != null) {
return previous < next ? Math.min(previous + 1, next) : next;
}
if (previous != null) {
return previous + 1;
}
return next;
}
function findNearestAssistantMessageIndex(
items: ChatItem[],
toolTimestamp: number | null,
): number | null {
let currentTurnStart = 0;
let currentTurnEnd = items.length;
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item?.kind !== "message") {
continue;
}
const normalized = safeNormalizeMessage(item.message);
if (!normalized || normalizeRoleForGrouping(normalized.role).toLowerCase() !== "user") {
continue;
}
if (
toolTimestamp != null &&
normalized.timestamp != null &&
normalized.timestamp > toolTimestamp
) {
currentTurnEnd = index;
break;
}
if (
toolTimestamp == null ||
normalized.timestamp == null ||
normalized.timestamp <= toolTimestamp
) {
currentTurnStart = index + 1;
}
}
const assistantEntries = items
.map((item, index) => {
if (item.kind !== "message") {
if (index < currentTurnStart || index >= currentTurnEnd || item.kind !== "message") {
return null;
}
const message = item.message as Record<string, unknown>;
@@ -245,6 +340,28 @@ function findNearestAssistantMessageIndex(
return assistantEntries[assistantEntries.length - 1]?.index ?? null;
}
function findCanvasInsertionIndex(items: ChatItem[], toolTimestamp: number | null): number {
if (toolTimestamp == null) {
return items.length;
}
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
if (item?.kind !== "message") {
continue;
}
const normalized = safeNormalizeMessage(item.message);
if (
normalized &&
normalizeRoleForGrouping(normalized.role).toLowerCase() === "user" &&
normalized.timestamp != null &&
normalized.timestamp > toolTimestamp
) {
return index;
}
}
return items.length;
}
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
const result: Array<ChatItem | MessageGroup> = [];
let currentGroup: MessageGroup | null = null;
@@ -628,7 +745,12 @@ function coalesceToolActivityMessages(items: ChatItem[]): ChatItem[] {
}
function assistantGroupHasReplyText(group: MessageGroup): boolean {
return group.messages.some(({ message }) => Boolean(extractTextCached(message)?.trim()));
return group.messages.some(({ message }) => {
if (extractTextCached(message)?.trim()) {
return true;
}
return safeNormalizeMessage(message)?.content.some((block) => block.type === "canvas") ?? false;
});
}
function assistantGroupIsForwardedBoundary(group: MessageGroup): boolean {
@@ -1108,6 +1230,34 @@ function resolveHistoryStartIndex(
return startIndex;
}
function expandHistoryStartForPersistedPreviews(messages: unknown[], historyStart: number): number {
const firstVisible = safeNormalizeMessage(messages[historyStart]);
if (!firstVisible || normalizeRoleForGrouping(firstVisible.role).toLowerCase() !== "assistant") {
return historyStart;
}
let expandedStart = historyStart;
for (let index = historyStart - 1; index >= 0; index -= 1) {
const message = messages[index];
const normalized = safeNormalizeMessage(message);
if (!normalized) {
continue;
}
const normalizedRole = normalized.role.toLowerCase();
const role = normalizeRoleForGrouping(normalized.role).toLowerCase();
if (role === "user" || role === "system") {
break;
}
if (normalizedRole === "toolresult" && extractChatMessagePreview(message)) {
expandedStart = index;
continue;
}
if (role === "assistant" && hasRenderableNormalizedMessage(message)) {
break;
}
}
return expandedStart;
}
export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGroup> {
let items: ChatItem[] = [];
const historyRenderLimit = resolveHistoryRenderLimit(
@@ -1118,20 +1268,32 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
(message) => !isAssistantHeartbeatAckForDisplay(message),
);
const tools = Array.isArray(props.toolMessages) ? props.toolMessages : [];
const liftedCanvasSources = tools
.map((tool) => extractChatMessagePreview(tool))
.filter((entry) => Boolean(entry)) as Array<{
preview: Extract<NonNullable<ToolCard["preview"]>, { kind: "canvas" }>;
text: string | null;
timestamp: number | null;
}>;
const liftedCanvasSources = tools.flatMap((message, index) => {
const source = extractChatMessagePreview(message);
return source ? [{ ...source, message, index }] : [];
});
const searchFiltering = props.searchOpen === true && Boolean(props.searchQuery?.trim());
const persistedCanvasIdentities = new Set<string>();
for (const message of history) {
const source = extractChatMessagePreview(message);
if (!source) {
continue;
}
const baseIdentity = canvasPreviewBaseIdentity(message, source);
if (baseIdentity) {
// fetchMcpAppView assigns a fresh viewId to every invocation. Matching the call and
// view therefore identifies the same preview while still tolerating a reused call ID.
persistedCanvasIdentities.add(baseIdentity);
}
}
const historyStart = resolveHistoryStartIndex(history, props.showToolCalls, historyRenderLimit);
const previewHistoryStart = expandHistoryStartForPersistedPreviews(history, historyStart);
const hiddenHistoryCount = countVisibleHistoryMessages(
history.slice(0, historyStart),
history.slice(0, previewHistoryStart),
props.showToolCalls,
);
const visibleHistoryCount = countVisibleHistoryMessages(
history.slice(historyStart),
history.slice(previewHistoryStart),
props.showToolCalls,
);
if (hiddenHistoryCount > 0) {
@@ -1145,7 +1307,7 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
},
});
}
for (let i = historyStart; i < history.length; i++) {
for (let i = previewHistoryStart; i < history.length; i++) {
const msg = history[i];
const normalized = safeNormalizeMessage(msg);
if (!normalized) {
@@ -1171,7 +1333,23 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
continue;
}
if (!props.showToolCalls && normalized.role.toLowerCase() === "toolresult") {
const isToolResult = normalized.role.toLowerCase() === "toolresult";
const persistedCanvasSource = isToolResult ? extractChatMessagePreview(msg) : null;
const renderPersistedPreview =
persistedCanvasSource != null &&
(!searchFiltering || turnHasMatchingAssistant(history, i, props.searchQuery ?? ""));
if (persistedCanvasSource && renderPersistedPreview) {
items.push({
kind: "message",
key: `${messageKey(msg, i)}:canvas`,
message: createCanvasAssistantMessage(
persistedCanvasSource,
persistedCanvasSource.timestamp ?? transcriptPositionTimestamp(history, i),
),
});
}
if (!props.showToolCalls && isToolResult) {
continue;
}
@@ -1190,13 +1368,20 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
});
}
const queuedSends = Array.isArray(props.queue) ? props.queue : [];
for (const queued of queuedSends) {
const activeRunQueuedSends = queuedSends.filter((queued) => queued.sendState === "waiting-model");
const futureQueuedSends = queuedSends.filter((queued) => !activeRunQueuedSends.includes(queued));
const futureQueuedTimestamp = futureQueuedSends.reduce<number | null>(
(earliest, queued) =>
earliest == null ? queued.createdAt : Math.min(earliest, queued.createdAt),
null,
);
const appendQueuedSend = (queued: ChatQueueItem) => {
if (!shouldRenderQueuedSendInThread(queued)) {
continue;
return;
}
const message = queuedSendThreadMessage(queued);
if (!message) {
continue;
return;
}
const searchQuery = props.searchQuery ?? "";
if (
@@ -1204,17 +1389,46 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
searchQuery.trim() &&
!messageMatchesSearchQuery(message, searchQuery)
) {
continue;
return;
}
items.push({
kind: "message",
key: `pending-send:${queued.id}`,
message,
});
};
for (const queued of activeRunQueuedSends) {
appendQueuedSend(queued);
}
for (const liftedCanvasSource of liftedCanvasSources) {
const baseIdentity = canvasPreviewBaseIdentity(liftedCanvasSource.message, liftedCanvasSource);
if (baseIdentity && persistedCanvasIdentities.has(baseIdentity)) {
continue;
}
const assistantIndex = findNearestAssistantMessageIndex(items, liftedCanvasSource.timestamp);
if (assistantIndex == null) {
if (searchFiltering) {
continue;
}
const insertionIndex = findCanvasInsertionIndex(items, liftedCanvasSource.timestamp);
const nextItem = items[insertionIndex];
const nextTimestamp =
nextItem?.kind === "message" ? rawMessageTimestamp(nextItem.message) : null;
const boundaryTimestamp =
nextTimestamp == null
? futureQueuedTimestamp
: futureQueuedTimestamp == null
? nextTimestamp
: Math.min(nextTimestamp, futureQueuedTimestamp);
const timestamp =
liftedCanvasSource.timestamp != null && boundaryTimestamp != null
? Math.min(liftedCanvasSource.timestamp, boundaryTimestamp)
: liftedCanvasSource.timestamp;
items.splice(insertionIndex, 0, {
kind: "message",
key: `${messageKey(liftedCanvasSource.message, liftedCanvasSource.index + history.length)}:canvas`,
message: createCanvasAssistantMessage(liftedCanvasSource, timestamp),
});
continue;
}
const item = items[assistantIndex];
@@ -1230,6 +1444,9 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
),
};
}
for (const queued of futureQueuedSends) {
appendQueuedSend(queued);
}
items = items.filter(
(item) => item.kind !== "message" || hasRenderableNormalizedMessage(item.message),
);