From e0e46b48db8b4bb9a77c9b8d0e1463cb13621e62 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sun, 11 Oct 2026 20:11:36 -0700 Subject: [PATCH] Render MCP App previews when hosts strip private metadata Mirror artifact and workspace payloads into structuredContent (appPreview, appWorkspace), accept event_id on artifacts_preview, and let the app reopen a compact selection through artifacts_preview when only a version id arrives. --- cloudflare/managed-service.ts | 7 ++++++- cloudflare/service.ts | 2 +- cloudflare/worker.test.ts | 2 ++ e2e/mcp-app.test.ts | 26 ++++++++++++++++++++++++++ src/mcp/app.test.ts | 3 +++ src/mcp/local-tools.ts | 6 +++--- src/mcp/workspace-contract.ts | 4 ++-- src/runtime/mcp-app.ts | 15 +++++++++++++-- src/runtime/mcp-workspace.ts | 6 +++--- 9 files changed, 59 insertions(+), 12 deletions(-) diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index fcfbce4..5373403 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -79,7 +79,12 @@ export class ManagedArtifactService { if (name === "artifacts_tool") return galleryAction(this, args); if (name === "artifacts_source") return gallerySource(this, args); if (["artifacts_library", "artifacts_working", "artifacts_search", "artifacts_mentions"].includes(name)) return workspaceTool(this, name, args); - if (name === "artifacts_preview") name = "artifact_open"; + if (name === "artifacts_preview") { + // Hosts may strip _meta before the MCP App sees it; mirror the preview payload. + const opened = await this.callTool("artifact_open", args); + const artifact = opened._meta?.artifact; + return artifact ? { ...opened, structuredContent: { ...opened.structuredContent, appPreview: artifact } } : opened; + } if (name.startsWith("app_")) return this.nativeTool(name, args); if (["artifact_guide", "script_guide", "plugins_list", "plugin_guide", "artifact_plugin_call"].includes(name)) return this.physical({ libraryKey: this.libraryKey, workspace: this.workspace }).callTool(name, args); const kind = name.startsWith("script_") ? "script" : name === "artifact_link" ? args.kind as "artifact" | "script" : "artifact"; diff --git a/cloudflare/service.ts b/cloudflare/service.ts index e13050d..2500768 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -161,7 +161,7 @@ export class CloudArtifactService { const snapshot = await this.snapshot(args as { name?: string; version_id?: string; event_id?: string }); const preview = await this.preview(snapshot); const { _meta, ...payload } = preview; - return { ...text(payload, !preview.ok), structuredContent: payload, ...(_meta ? { _meta } : {}) }; + return { ...text(payload, !preview.ok), structuredContent: { ...payload, ...(name === "artifacts_preview" && _meta?.artifact ? { appPreview: _meta.artifact } : {}) }, ...(_meta ? { _meta } : {}) }; } case "artifact_schedule": case "artifact_runs": { diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 061de57..913a01d 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -942,6 +942,7 @@ test("MCP workspace tools admit cross-workspace previews and resources while sup const library = await client.callTool({ name: "artifacts_library", arguments: {} }); expect((library._meta as any).workspace.items).toContainEqual(expect.objectContaining({ name: "workspace-preview", workspace: "workspace-tools" })); + expect((library.structuredContent as any).appWorkspace.items).toContainEqual(expect.objectContaining({ name: "workspace-preview", workspace: "workspace-tools" })); const working = await client.callTool({ name: "artifacts_working", arguments: {} }); expect((working._meta as any).workspace.items.some((item: any) => item.workspace === "workspace-tools")).toBe(true); const mentions = await client.callTool({ name: "artifacts_mentions", arguments: { query: "WORKSPACE-PREVIEW" } }); @@ -953,6 +954,7 @@ test("MCP workspace tools admit cross-workspace previews and resources while sup const opened = await client.callTool({ name: "artifacts_preview", arguments: { workspace: "workspace-tools", version_id: versionId } }); expect(opened.isError).not.toBe(true); expect((opened._meta as any).artifact).toMatchObject({ workspace: "workspace-tools", versionId }); + expect((opened.structuredContent as any).appPreview).toMatchObject({ workspace: "workspace-tools", versionId, js: expect.any(String) }); const after = payload(await remote.callTool({ name: "artifact_version", arguments: { version_id: versionId } })); expect(after.events).toHaveLength(1); } finally { await remote.close(); await team.close(); } diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index b40e87c..29618dc 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -276,6 +276,32 @@ test("renders and replaces interactive artifacts through the MCP Apps bridge", a await page.close(); }, 30_000); +test("renders artifact previews when the host omits private metadata", async () => { + const meta = await resultFor(INTERACTIVE_ARTIFACT, "chosen", { count: 4 }); + meta.artifact.workspace = "research"; + const { page, app, errors } = await openHost(); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], structuredContent: { appPreview: meta.artifact } }), meta); + await app.getByText("Count: 4").waitFor(); + + await page.evaluate(meta => window.mcpHost!.setServerToolResult({ content: [], structuredContent: { appPreview: meta.artifact } }), meta); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], structuredContent: { artifact: { + versionId: meta.artifact.versionId, eventId: meta.artifact.eventId, workspace: "research", + } } }), meta); + await page.waitForFunction(() => window.mcpHost!.serverToolCalls.length === 1); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls[0])).toMatchObject({ name: "artifacts_preview", arguments: { + version_id: meta.artifact.versionId, event_id: meta.artifact.eventId, workspace: "research", + } }); + await app.getByText("Count: 4").waitFor(); + + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], structuredContent: { preview: { artifact: { + versionId: meta.artifact.versionId, workspace: "research", + } } } }), meta); + await page.waitForFunction(() => window.mcpHost!.serverToolCalls.length === 2); + await app.getByText("Count: 4").waitFor(); + expect(errors).toEqual([]); + await page.close(); +}, 30_000); + test("applies natural, capped, shrinking, narrow, and fixed inline sizes without feedback", async () => { const meta = await resultFor(RESIZABLE_ARTIFACT, "resizable"); const { page, app, errors } = await openHost(); diff --git a/src/mcp/app.test.ts b/src/mcp/app.test.ts index 18157a7..36a0887 100644 --- a/src/mcp/app.test.ts +++ b/src/mcp/app.test.ts @@ -34,6 +34,9 @@ test("create, show, edit and restore deliver the actual artifact without opening const versionId = created._meta!.artifact!.versionId; const shown = await call("artifact_open", { name: "overview" }); expect(shown._meta?.artifact?.versionId).toBe(versionId); + expect(shown.structuredContent).not.toHaveProperty("appPreview"); + const appPreview = await call("artifacts_preview", { version_id: versionId }); + expect(appPreview.structuredContent.appPreview.js).toContain("Embedded sample data."); const edited = await call("artifact_edit", { name: "overview", edits: [{ old_text: "Embedded sample data.", new_text: "Updated inline." }] }); expect(edited._meta?.artifact?.js).toContain("Updated inline."); expect(edited.structuredContent.applied).toBe(true); diff --git a/src/mcp/local-tools.ts b/src/mcp/local-tools.ts index f1abc57..1f36a86 100644 --- a/src/mcp/local-tools.ts +++ b/src/mcp/local-tools.ts @@ -85,7 +85,7 @@ async function callTool( } if (name === "artifacts_preview") { if (args.workspace !== undefined && args.workspace !== service.artifactsDir) throw new Error("artifact is outside this workspace"); - return previewResult(await artifactAppResult(service, args as { name?: string; version_id?: string })); + return previewResult(await artifactAppResult(service, args as { name?: string; version_id?: string; event_id?: string }), true); } const deliver = args.preview !== false && (appClients.get(service) ?? true); if (args.preview !== undefined && typeof args.preview !== "boolean") throw new Error("preview must be a boolean"); @@ -155,9 +155,9 @@ async function callTool( throw new Error(`unknown tool: ${name}`); } -function previewResult(result: Awaited>) { +function previewResult(result: Awaited>, forApp = false) { const { _meta, ...payload } = result; - return { ...text(`${result.check}\n\n${JSON.stringify(payload, null, 2)}`, !result.ok), structuredContent: payload, ...(_meta ? { _meta } : {}) }; + return { ...text(`${result.check}\n\n${JSON.stringify(payload, null, 2)}`, !result.ok), structuredContent: { ...payload, ...(forApp && _meta?.artifact ? { appPreview: _meta.artifact } : {}) }, ...(_meta ? { _meta } : {}) }; } async function withPreview(service: ArtifactService, result: { ok: boolean; check: string; [key: string]: unknown }, selection: { name: string }, deliver = true) { diff --git a/src/mcp/workspace-contract.ts b/src/mcp/workspace-contract.ts index cfee892..10807df 100644 --- a/src/mcp/workspace-contract.ts +++ b/src/mcp/workspace-contract.ts @@ -48,7 +48,7 @@ export const ARTIFACTS_WORKSPACE_TOOLS = [ { name: "artifacts_preview", description: "Open an accessible React artifact revision in the working view. Does not change source or live data.", annotations: readOnly, _meta: { ui }, - inputSchema: { type: "object", properties: { name: { type: "string", minLength: 1 }, workspace: { type: "string", minLength: 1 }, version_id: { type: "string", minLength: 1 } }, oneOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, + inputSchema: { type: "object", properties: { name: { type: "string", minLength: 1 }, workspace: { type: "string", minLength: 1 }, version_id: { type: "string", minLength: 1 }, event_id: { type: "string", minLength: 1 } }, oneOf: [{ required: ["name"] }, { required: ["version_id"] }], additionalProperties: false }, }, { name: "artifacts_mentions", description: "Search accessible artifact and script projects for composer mentions.", @@ -82,7 +82,7 @@ export function parseArtifactResourceUri(uri: string) { export function workspaceResult(payload: ArtifactWorkspacePayload) { return { content: [{ type: "text" as const, text: JSON.stringify({ workspace: payload.workspace, artifacts: payload.items, next_offset: payload.nextOffset }) }], - structuredContent: { workspace: payload.workspace, artifacts: payload.items, next_offset: payload.nextOffset }, + structuredContent: { workspace: payload.workspace, artifacts: payload.items, next_offset: payload.nextOffset, appWorkspace: payload }, _meta: { workspace: payload }, }; } diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index bedfee4..4a6913f 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -208,6 +208,9 @@ function clear() { updateDisplay(); } +type CompactSelection = { versionId?: string; eventId?: string; workspace?: string }; +type CompactToolResult = { artifact?: CompactSelection; preview?: { artifact?: CompactSelection }; appPreview?: ArtifactAppPayload; appWorkspace?: ArtifactWorkspacePayload }; + async function openSelection(selection: ArtifactSelection) { if (fileEditor?.hasUnsavedChanges()) throw new Error("Save or discard the file draft before opening another view."); if (!host.capabilities().serverTools) throw new Error("Browsing artifacts is unavailable in this host."); @@ -368,12 +371,20 @@ app.ontoolresult = result => { if (result.structuredContent?.file && !result.isError) return; ++opening; if (result.isError) { status.textContent = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Artifact could not be rendered."; return; } - const artifact = result._meta?.artifact as ArtifactAppPayload | undefined; + // Some hosts strip private _meta before forwarding results; fall back to the + // structuredContent mirrors, or reopen a compact selection through the server. + const compact = result.structuredContent as CompactToolResult | undefined; + const artifact = (result._meta?.artifact ?? compact?.appPreview) as ArtifactAppPayload | undefined; if (artifact?.js) { if (workspaceView) { workspaceView.refresh(); return; } renderArtifact(artifact); return; } - const workspace = result._meta?.workspace as ArtifactWorkspacePayload | undefined; + const selected = compact?.artifact ?? compact?.preview?.artifact; + if (selected?.versionId && host.capabilities().serverTools) { + void openSelection({ workspace: selected.workspace, version_id: selected.versionId, event_id: selected.eventId }).catch(error => { status.textContent = String(error); }); + return; + } + const workspace = (result._meta?.workspace ?? compact?.appWorkspace) as ArtifactWorkspacePayload | undefined; if (workspace) { if (workspaceView && lastWorkspace?.view === workspace.view) workspaceView.refresh(); else showWorkspace(workspace); diff --git a/src/runtime/mcp-workspace.ts b/src/runtime/mcp-workspace.ts index b36e8ef..e625926 100644 --- a/src/runtime/mcp-workspace.ts +++ b/src/runtime/mcp-workspace.ts @@ -9,7 +9,7 @@ import { GalleryToolError, type GalleryTransport, type MutationResult } from ".. import type { SourceSnapshot } from "../gallery/drafts"; import { editableProject } from "../gallery/project-editor"; -export type ArtifactSelection = { name?: string; workspace?: string; version_id?: string; route?: string; kind?: "artifact" | "script"; tab?: "preview" | "source" }; +export type ArtifactSelection = { name?: string; workspace?: string; version_id?: string; event_id?: string; route?: string; kind?: "artifact" | "script"; tab?: "preview" | "source" }; /** Deep links contain authenticated selectors, never file-transfer credentials. */ export function parseArtifactDeepLink(value: string): ArtifactSelection | null { @@ -31,7 +31,7 @@ export async function fetchArtifactPreview(app: App, selection: ArtifactSelectio const { route: _route, kind: _kind, tab: _tab, ...args } = selection; const result = await app.callServerTool({ name: "artifacts_preview", arguments: args }); if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Unable to open artifact"); - const artifact = result._meta?.artifact as ArtifactAppPayload | undefined; + const artifact = (result._meta?.artifact ?? result.structuredContent?.appPreview) as ArtifactAppPayload | undefined; if (!artifact?.js) throw new Error("The server returned no artifact preview"); return artifact; } @@ -86,7 +86,7 @@ export function mountWorkspace(root: HTMLElement, app: App, initial: ArtifactWor const search = async (query: string, offset: number) => { const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, offset, view: "library" } }); if (result.isError) resultValue(result); - const payload = result._meta?.workspace as ArtifactWorkspacePayload | undefined; + const payload = (result._meta?.workspace ?? result.structuredContent?.appWorkspace) as ArtifactWorkspacePayload | undefined; if (!payload) throw new Error("The server returned no library catalog"); return workspaceGallery(payload); };