From 1663c8801d2c75407376ccca26057ebc82418731 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:30:34 -0600 Subject: [PATCH] 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 --- docs/cli/mcp.md | 4 +- src/agents/agent-bundle-mcp-materialize.ts | 11 +- ...agent-bundle-mcp-tools.materialize.test.ts | 40 ++- src/agents/mcp-app-sandbox.ts | 13 +- src/agents/mcp-ui-resource.test.ts | 39 +- src/agents/mcp-ui-resource.ts | 68 +++- src/chat/canvas-render.test.ts | 21 +- src/chat/canvas-render.ts | 54 ++- src/gateway/mcp-app-reconstruction.test.ts | 262 ++++++++++++++ src/gateway/mcp-app-reconstruction.ts | 337 ++++++++++++++++++ src/gateway/mcp-app-sandbox-http.test.ts | 6 +- src/gateway/server-methods/mcp-app.test.ts | 52 ++- src/gateway/server-methods/mcp-app.ts | 67 ++-- ui/src/components/mcp-app-view.test.ts | 42 ++- ui/src/components/mcp-app-view.ts | 27 +- ui/src/lib/chat/chat-types.ts | 8 +- ui/src/lib/chat/message-normalizer.ts | 12 +- ui/src/pages/chat/chat-thread.test.ts | 255 +++++++++++++ ui/src/pages/chat/chat-thread.ts | 263 ++++++++++++-- 19 files changed, 1498 insertions(+), 83 deletions(-) create mode 100644 src/gateway/mcp-app-reconstruction.test.ts create mode 100644 src/gateway/mcp-app-reconstruction.ts diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 69948414974d..60609abfafac 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -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 diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index 2ea3820cb948..cb044fe803da 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -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).mcpAppPreview = - buildMcpAppCanvasPayload(view); + (agentResult.details as Record).mcpAppPreview = buildMcpAppCanvasPayload( + { + ...view, + ...(result["_meta"] !== undefined ? { resultMetaState: "unavailable" as const } : {}), + }, + ); } } return agentResult; diff --git a/src/agents/agent-bundle-mcp-tools.materialize.test.ts b/src/agents/agent-bundle-mcp-tools.materialize.test.ts index 9befc9259249..401c78562efc 100644 --- a/src/agents/agent-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/agent-bundle-mcp-tools.materialize.test.ts @@ -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"]), + }), ); }); diff --git a/src/agents/mcp-app-sandbox.ts b/src/agents/mcp-app-sandbox.ts index 3d74815154a4..983793928e0f 100644 --- a/src/agents/mcp-app-sandbox.ts +++ b/src/agents/mcp-app-sandbox.ts @@ -113,7 +113,12 @@ export function buildMcpAppSandboxProxyHtml(): string { `; @@ -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()); diff --git a/src/agents/mcp-ui-resource.test.ts b/src/agents/mcp-ui-resource.test.ts index 47c98756b95c..4ed0e6e0de20 100644 --- a/src/agents/mcp-ui-resource.test.ts +++ b/src/agents/mcp-ui-resource.test.ts @@ -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("")).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: "demo", + }, + ], + })); + 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, + }); + }); }); diff --git a/src/agents/mcp-ui-resource.ts b/src/agents/mcp-ui-resource.ts index e8f4d3e205c7..cb34e743b8df 100644 --- a/src/agents/mcp-ui-resource.ts +++ b/src/agents/mcp-ui-resource.ts @@ -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 | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -176,12 +197,25 @@ export async function fetchMcpAppView(params: { serverName: string; toolName: string; uiResourceUri: string; + toolCallId?: string; toolInput: unknown; toolResult: CallToolResult; allowedAppToolNames?: ReadonlySet; -}): 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 } : {}), + }, }; } diff --git a/src/chat/canvas-render.test.ts b/src/chat/canvas-render.test.ts index 883a2f170777..212715691a27 100644 --- a/src/chat/canvas-render.test.ts +++ b/src/chat/canvas-render.test.ts @@ -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", () => { diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index a9a269c705dd..5f899a896be9 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -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 | 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; diff --git a/src/gateway/mcp-app-reconstruction.test.ts b/src/gateway/mcp-app-reconstruction.test.ts new file mode 100644 index 000000000000..1e5d7b79e31e --- /dev/null +++ b/src/gateway/mcp-app-reconstruction.test.ts @@ -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 }); + }); +}); diff --git a/src/gateway/mcp-app-reconstruction.ts b/src/gateway/mcp-app-reconstruction.ts new file mode 100644 index 000000000000..e2966e0586b9 --- /dev/null +++ b/src/gateway/mcp-app-reconstruction.ts @@ -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; +type TranscriptResult = Omit & { modelToolName: string }; +type TranscriptResultRead = + | { kind: "restorable"; value: TranscriptResult } + | { kind: "unavailable" }; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(record: Record | 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, details: Record) { + 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 { + 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> { + const state = globalThis as Record; + const existing = state[MCP_APP_RESTORE_IN_FLIGHT_KEY] as + | Map> + | undefined; + if (existing) { + return existing; + } + const created = new Map>(); + state[MCP_APP_RESTORE_IN_FLIGHT_KEY] = created; + return created; +} + +async function restoreMcpAppViewOnce(params: { + cfg: OpenClawConfig; + sessionKey: string; + viewId: string; +}): Promise { + 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 { + 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; +} diff --git a/src/gateway/mcp-app-sandbox-http.test.ts b/src/gateway/mcp-app-sandbox-http.test.ts index 0f64f6c68f81..c098bdc4f8fb 100644 --- a/src/gateway/mcp-app-sandbox-http.test.ts +++ b/src/gateway/mcp-app-sandbox-http.test.ts @@ -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", () => { diff --git a/src/gateway/server-methods/mcp-app.test.ts b/src/gateway/server-methods/mcp-app.test.ts index 616d980367ce..a524892d09f5 100644 --- a/src/gateway/server-methods/mcp-app.test.ts +++ b/src/gateway/server-methods/mcp-app.test.ts @@ -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) { +async function invoke( + method: keyof typeof mcpAppHandlers, + params: Record, + mcpAppsEnabled = true, +) { const respond = vi.fn(); await expectDefined( mcpAppHandlers[method], @@ -84,7 +92,7 @@ async function invoke(method: keyof typeof mcpAppHandlers, params: Record 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: "demo" }); }); }); diff --git a/src/gateway/server-methods/mcp-app.ts b/src/gateway/server-methods/mcp-app.ts index 120952f22140..bc5b78dba632 100644 --- a/src/gateway/server-methods/mcp-app.ts +++ b/src/gateway/server-methods/mcp-app.ts @@ -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, 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): { +async function requireActiveView( + params: Record, + 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( params: Record, kind: "read" | "tool", operation: (active: { runtime: SessionMcpRuntime; view: McpAppViewLease }) => Promise | T, + cfg?: OpenClawConfig, ): Promise { - 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 }) => { diff --git a/ui/src/components/mcp-app-view.test.ts b/ui/src/components/mcp-app-view.test.ts index 65d4d151cc36..0a0f998fc910 100644 --- a/ui/src/components/mcp-app-view.test.ts +++ b/ui/src/components/mcp-app-view.test.ts @@ -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; + }); }); diff --git a/ui/src/components/mcp-app-view.ts b/ui/src/components/mcp-app-view.ts index 4c4d03301e42..794b7c457801 100644 --- a/ui/src/components/mcp-app-view.ts +++ b/ui/src/components/mcp-app-view.ts @@ -29,6 +29,25 @@ type HostContext = NonNullable< type HostCapabilities = ConstructorParameters[2]; type HostSandboxCsp = NonNullable["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 { + await Promise.race([ + new Promise((resolve) => { + scheduleFrame(() => { + scheduleFrame(() => resolve()); + }); + }), + new Promise((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 && diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index cbb96f5eb314..5d7944ebff82 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -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; + }; }; }; diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 54bed9132ee9..b3eb6b51a500 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -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 } : {}), + }, + } : {}), }; } diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 7125b3f34d31..01c04141fe22 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -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 { diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index 5e6a1c3c1725..ecade8741b06 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -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, { 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; @@ -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 { const result: Array = []; 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 { let items: ChatItem[] = []; const historyRenderLimit = resolveHistoryRenderLimit( @@ -1118,20 +1268,32 @@ export function buildChatItems(props: BuildChatItemsProps): Array !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, { 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(); + 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 queued.sendState === "waiting-model"); + const futureQueuedSends = queuedSends.filter((queued) => !activeRunQueuedSends.includes(queued)); + const futureQueuedTimestamp = futureQueuedSends.reduce( + (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 item.kind !== "message" || hasRenderableNormalizedMessage(item.message), );