Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cloudflare/managed-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion cloudflare/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions cloudflare/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } });
Expand All @@ -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(); }
Expand Down
26 changes: 26 additions & 0 deletions e2e/mcp-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/mcp/local-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -155,9 +155,9 @@ async function callTool(
throw new Error(`unknown tool: ${name}`);
}

function previewResult(result: Awaited<ReturnType<typeof artifactAppResult>>) {
function previewResult(result: Awaited<ReturnType<typeof artifactAppResult>>, 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) {
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/workspace-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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 },
};
}
15 changes: 13 additions & 2 deletions src/runtime/mcp-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions src/runtime/mcp-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
};
Expand Down
Loading