diff --git a/bun.lock b/bun.lock index af14bac..2c2973a 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,8 @@ "@codemirror/state": "^6.7.4", "@codemirror/view": "^6.43.11", "@lezer/highlight": "^1.2.3", - "@modelcontextprotocol/ext-apps": "^1.7.5", + "@modelcontextprotocol/ext-apps": "1.7.5", + "@openai/mcp-extensions": "0.1.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "better-auth": "1.7.3", @@ -74,6 +75,8 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], @@ -258,6 +261,8 @@ "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + "@openai/mcp-extensions": ["@openai/mcp-extensions@0.1.0", "", { "dependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "4.4.3" }, "peerDependencies": { "@modelcontextprotocol/ext-apps": "^1.7.5", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalPeers": ["@modelcontextprotocol/ext-apps"] }, "sha512-AkQ7hRS9DSUTBHgwVwd+BO6yh1y75SldqNTt8YISUnJODKD3Esg+zC2mq96FMmgzCTV7VRykkgzBzkF6W1zkdw=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], @@ -588,6 +593,8 @@ "@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@openai/mcp-extensions/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "better-call/set-cookie-parser": ["set-cookie-parser@3.1.2", "", {}, "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw=="], diff --git a/cloudflare/library.ts b/cloudflare/library.ts index d37ff23..5c705c5 100644 --- a/cloudflare/library.ts +++ b/cloudflare/library.ts @@ -381,7 +381,7 @@ export class ArtifactLibrary extends DurableObject { recordServe(input: { workspace: string; name: string; source: string; server_source?: string | null; project?: ArtifactProject; runtime: string; initial_state: Record; mode: "live" | "replay" | "preview"; - pane_id?: string; session_id?: string; version_id?: string; compiled_id?: string; + pane_id?: string; session_id?: string; version_id?: string; compiled_id?: string; record_event?: boolean; }) { const workspace = workspaceName(input.workspace); const name = artifactName(input.name); const source = sourceText(input.source); const runtime = runtimeName(input.runtime); @@ -414,6 +414,7 @@ export class ArtifactLibrary extends DurableObject { } else { version = this.capture(workspace, name, source, server_source, project, runtime, "served", null, false, input.compiled_id); } + if (input.record_event === false) return { version, event: null }; const event: ServeEvent = { id: uuid(), version_id: version.id, served_at: now(), pane_id: input.pane_id ?? null, session_id: input.session_id ?? null, mode: input.mode, initial_state, runtime, diff --git a/cloudflare/managed-service.ts b/cloudflare/managed-service.ts index d673f7d..4886516 100644 --- a/cloudflare/managed-service.ts +++ b/cloudflare/managed-service.ts @@ -8,11 +8,12 @@ import type { ArtifactHttpRequest } from "../src/httpTypes"; import type { ArtifactFileRequest } from "../src/sdk/files"; import type { GalleryArtifact, GalleryData } from "../src/gallery/types"; import { CloudArtifactService } from "./service"; +import { workspaceTool } from "./workspace-tools"; import { parseProjectArchive } from "../src/project-archive"; import { ownershipName } from "./ownership"; const text = (value: unknown): CallToolResult => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }); -type Selection = { name?: string; version_id?: string; event_id?: string }; +type Selection = { workspace?: string; name?: string; version_id?: string; event_id?: string }; /** Management requests select an owner; execution continues on immutable storage. */ export class ManagedArtifactService { @@ -44,7 +45,7 @@ export class ManagedArtifactService { private async callSelectedTool(target: ArtifactTarget, name: string, args: Record) { const changesGallery = /^(artifact|script)_(write|edit|restore|remix|import|open|link)$/.test(name); - try { return await this.physical(target).callTool(name, args); } + try { return await this.physical(target).callTool(name, { ...args, workspace: target.workspace }); } finally { // A failed compile may still have saved a draft. Resolve current ownership // after the operation, including writes admitted before a library move. @@ -66,6 +67,8 @@ export class ManagedArtifactService { } async callTool(name: string, args: Record): Promise { + 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.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"; @@ -75,7 +78,7 @@ export class ManagedArtifactService { if (!history) for (const row of rows) Object.assign(row, await this.linkDetails(kind, row.workspace, kind === "artifact" ? row.id! : row.name)); return text({ [history ? "versions" : kind === "script" ? "scripts" : "artifacts"]: rows, next_offset: rows.length === 100 ? offset + 100 : null }); } - const selection = this.selection(kind, { name: args.name as string | undefined, version_id: args.version_id as string | undefined }); + const selection = this.selection(kind, { name: args.name as string | undefined, version_id: args.version_id as string | undefined, ...(typeof args.workspace === "string" ? { workspace: args.workspace } : {}) }); if (name.endsWith("_import")) { parseProjectArchive(args.archive, kind); const target = await this.links.admit(this.selection(kind, { name: args.new_name as string }), true); @@ -125,7 +128,7 @@ export class ManagedArtifactService { async request(selection: Selection, request: ArtifactHttpRequest) { return (await this.service("artifact", selection)).request(selection, request); } async fileRequest(selection: Selection, request: ArtifactFileRequest) { return (await this.service("artifact", selection)).fileRequest(selection, request); } async preview(snapshot: Awaited>) { - const selection = snapshot.version_id ? { version_id: snapshot.version_id, event_id: (snapshot as { event_id?: string | null }).event_id ?? undefined } : { name: snapshot.name }; + const selection = snapshot.version_id ? { workspace: snapshot.workspace, version_id: snapshot.version_id, event_id: (snapshot as { event_id?: string | null }).event_id ?? undefined } : { workspace: snapshot.workspace, name: snapshot.name }; const service = await this.service("artifact", selection); // The gallery's snapshot and preview are separate calls. Re-read after fresh // ownership admission instead of reusing a possibly moved/recreated draft. diff --git a/cloudflare/mcp.ts b/cloudflare/mcp.ts index 030f286..e9fb25e 100644 --- a/cloudflare/mcp.ts +++ b/cloudflare/mcp.ts @@ -3,25 +3,37 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, - ErrorCode, McpError, + ErrorCode, McpError, type CallToolResult, } from "@modelcontextprotocol/sdk/types.js"; import { ARTIFACTS_APP_URI, ARTIFACTS_RESOURCE } from "../src/mcp/app-contract"; import shell from "../dist/cloudflare/mcp-app.json"; import * as validators from "../dist/cloudflare/tool-validators.js"; +import { artifactFileResult } from "../src/mcp/file-contract"; +import { clientSupportsApps, toolsForClient } from "../src/mcp/host-contract"; import { CLOUD_MCP_TOOLS } from "./tool-contract"; import type { CloudArtifactService } from "./service"; -export async function handleCloudMcp(request: Request, service: Pick, parsedBody?: unknown): Promise { +export async function handleCloudMcp(request: Request, service: Pick, parsedBody?: unknown): Promise { // Low-level SDK registration lets local and hosted transports share the same // JSON schemas, with argument validators generated from them at build time. const server = new Server({ name: "artifacts", version: "0.1.0" }, { capabilities: { tools: {}, resources: {} } }); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: CLOUD_MCP_TOOLS })); - const resource = service.fileStorage ? { ...ARTIFACTS_RESOURCE, _meta: { ui: { ...ARTIFACTS_RESOURCE._meta.ui, + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolsForClient(CLOUD_MCP_TOOLS, server.getClientCapabilities() === undefined || clientSupportsApps(server.getClientCapabilities())) })); + const resource = service.fileStorage ? { ...ARTIFACTS_RESOURCE, _meta: { ...ARTIFACTS_RESOURCE._meta, ui: { ...ARTIFACTS_RESOURCE._meta.ui, csp: { ...ARTIFACTS_RESOURCE._meta.ui.csp, connectDomains: [service.fileStorage.origin] }, } } } : ARTIFACTS_RESOURCE; server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [resource] })); - server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] })); + server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [{ uriTemplate: "artifact://project?workspace={workspace}&name={name}", name: "Artifact source", description: "Read an artifact source snapshot in the authenticated library.", mimeType: "application/json" }] })); server.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => { + if (params.uri.startsWith("artifact://")) { + const uri = new URL(params.uri); + if (uri.hostname !== "project" || uri.pathname || !uri.searchParams.get("workspace") || !uri.searchParams.get("name")) throw new McpError(ErrorCode.InvalidParams, "Invalid artifact resource"); + // Admission is repeated for every read; a previously issued mention URI is + // descriptive context, never an authorization capability. + const selection = { workspace: uri.searchParams.get("workspace")!, name: uri.searchParams.get("name")!, ...(uri.searchParams.has("version_id") ? { version_id: uri.searchParams.get("version_id")! } : {}) }; + const snapshot = await service.snapshot(selection); + if (snapshot.name !== selection.name) throw new McpError(ErrorCode.InvalidParams, "Artifact version does not match name"); + return { contents: [{ uri: params.uri, mimeType: "application/json", text: JSON.stringify({ name: snapshot.name, workspace: snapshot.workspace, version_id: snapshot.version_id ?? null, revision_token: snapshot.revision_token, description: `React artifact ${snapshot.name} in workspace ${snapshot.workspace}`, source: snapshot.source, server_source: snapshot.server_source, project: snapshot.project }) }] }; + } if (params.uri !== ARTIFACTS_APP_URI) throw new McpError(ErrorCode.InvalidParams, "Unknown resource"); return { contents: [{ ...resource, text: shell }] }; }); @@ -30,7 +42,14 @@ export async function handleCloudMcp(request: Request, service: Pick): Promise { + if (typeof args.workspace === "string" && args.workspace !== this.workspace && ["artifacts_preview", "artifact_request", "artifact_files", "artifact_read", "artifact_export"].includes(name)) { + const { workspace, ...selectedArgs } = args; + return new CloudArtifactService(this.library, workspace, this.backends, this.libraryKey, this.hosted, this.plugins, this.fileStorage).callTool(name, selectedArgs); + } + if (ARTIFACTS_WORKSPACE_TOOLS.some(tool => tool.name === name) && name !== "artifacts_preview") return workspaceTool(this, name, args); if (name.startsWith("script_")) return this.scripts.callTool(name, args); if (name === "artifact_link") return this.artifactLink(args); switch (name) { @@ -96,7 +104,7 @@ export class CloudArtifactService { if (this.hosted) await this.hosted.links.check(target, slug); const mutation = await this.library.importDraft({ workspace: this.workspace, name: target.name, source: archive.source, server_source: archive.server_source, project: archive.project, runtime }); const generation = await this.hosted?.links.begin(target); - return this.mutationResult(mutation, generation, this.hosted ? { slug, access: "private" } : {}); + return this.mutationResult(mutation, generation, this.hosted ? { slug, access: "private" } : {}, args.preview !== false); } case "artifact_remix": { const target = this.artifacts.target("artifact", args.new_name as string); @@ -104,7 +112,7 @@ export class CloudArtifactService { if (this.hosted) await this.hosted.links.check(target, slug); const mutation = await this.library.remix({workspace:this.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string,runtime}); const generation = await this.hosted?.links.begin(target); - return this.mutationResult(mutation, generation, this.hosted ? {slug,access:"private"} : {}); + return this.mutationResult(mutation, generation, this.hosted ? {slug,access:"private"} : {}, args.preview !== false); } case "artifact_write": { const target = this.artifacts.target("artifact", args.name as string); @@ -118,16 +126,16 @@ export class CloudArtifactService { const generation = await this.hosted?.links.begin(target); const previous = args.project === undefined ? undefined : await this.snapshot({name: args.name as string}).catch(error => { if (error instanceof Error && error.message.includes("artifact not found")) return undefined; throw error; }); const project = args.project === undefined ? undefined : await resolveProject(args.project, previous?.project, fetch, browserRuntime.sharedVersions); - return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined, project, expected_revision: args.expected_revision as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }); + return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined, project, expected_revision: args.expected_revision as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }, args.preview !== false); } case "artifact_edit": { const generation = await this.hosted?.links.begin(this.artifacts.target("artifact", args.name as string)); - return this.mutationResult(await this.library.editDraft({ workspace: this.workspace, name: args.name as string, file: args.file as string | undefined, part: args.part as "client" | "server" | undefined, edits: args.edits as ArtifactEdit[], expected_hash: args.expected_hash as string | undefined }), generation); + return this.mutationResult(await this.library.editDraft({ workspace: this.workspace, name: args.name as string, file: args.file as string | undefined, part: args.part as "client" | "server" | undefined, edits: args.edits as ArtifactEdit[], expected_hash: args.expected_hash as string | undefined }), generation, {}, args.preview !== false); } case "artifact_restore": { const version = await this.library.version({ workspace: this.workspace, id: args.version_id as string }); const generation = await this.hosted?.links.begin(this.artifacts.target("artifact", version.name)); - return this.mutationResult(await this.library.restore({ workspace: this.workspace, id: args.version_id as string, runtime }), generation); + return this.mutationResult(await this.library.restore({ workspace: this.workspace, id: args.version_id as string, runtime }), generation, {}, args.preview !== false); } case "artifact_typecheck": { const snapshot = await this.snapshot({ name: args.name as string }); @@ -142,6 +150,7 @@ export class CloudArtifactService { }); return text({ ok: compiled.ok, path: snapshot.path, check: formatArtifactCheck(compiled.diagnostics), diagnostics: compiled.diagnostics, bytes: compiled.js?.length ?? 0 }, !compiled.ok); } + case "artifacts_preview": case "artifact_open": { const snapshot = await this.snapshot(args as { name?: string; version_id?: string; event_id?: string }); const preview = await this.preview(snapshot); @@ -196,11 +205,11 @@ export class CloudArtifactService { runtime: compiled.artifact.runtime, compiled_id: compiled.artifact.id, initial_state: snapshot.state, mode: "preview", ...(snapshot.version_id ? { version_id: snapshot.version_id } : {}), }); - const artifact = { name: version.name, versionId: version.id, revision: version.revision, eventId: event.id, sourceHash: version.source_hash }; + const artifact = { workspace: snapshot.workspace, name: version.name, versionId: version.id, revision: version.revision, eventId: event!.id, sourceHash: version.source_hash }; return { ...base, ok: true, artifact, _meta: { artifact: { ...artifact, js: compiled.js, state: snapshot.state, server: snapshot.server_source !== null, ...(this.plugins ? { plugins: true } : {}), ...(this.fileStorage ? { files: true } : {}) } satisfies ArtifactAppPayload } }; } - private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}): Promise { + private async mutationResult(mutation: Mutation, generation?: number | null, settings: LinkUpdate = {}, deliverPreview = true): Promise { const { source, server_source, state, project, ...summary } = mutation; const target = this.artifacts.target("artifact", mutation.name); if (generation != null) { @@ -216,7 +225,7 @@ export class CloudArtifactService { // The draft is already committed. A delivery error must never look like a // rollback, and compilation must use that exact committed source snapshot. try { - const { _meta, ...details } = await this.preview(snapshot, compiled); + const { _meta, ...details } = deliverPreview ? await this.preview(snapshot, compiled) : await this.saveRevision(snapshot, compiled); const target = this.artifacts.target("artifact", mutation.name); if (generation != null && details.ok && "artifact" in details && (settings.slug !== undefined || await this.hosted!.links.find(target))) { const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.artifact.versionId, live: { id: details.artifact.versionId, revision: details.artifact.revision, revision_token: projectRevision(mutation.source, mutation.server_source, mutation.project ?? emptyProject()) } }); @@ -234,6 +243,16 @@ export class CloudArtifactService { } } + private async saveRevision(snapshot: Snapshot, compiled: Compiled) { + const { version } = await this.library.recordServe({ + workspace: snapshot.workspace, name: snapshot.name, source: snapshot.source, + server_source: snapshot.server_source, project: snapshot.project, runtime: compiled.artifact!.runtime, + compiled_id: compiled.artifact!.id, initial_state: snapshot.state, mode: "preview", record_event: false, + ...(snapshot.version_id ? { version_id: snapshot.version_id } : {}), + }); + return { ok: true, artifact: { workspace: snapshot.workspace, name: version.name, versionId: version.id, revision: version.revision, sourceHash: version.source_hash }, _meta: undefined }; + } + private async compile(snapshot: Snapshot): Promise { // Only mutations, explicit compile, and first link creation call this path. // The exact pair and compiler identity prevent client-only hashes from diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index 0142a91..470f790 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -1,3 +1,6 @@ +import { ARTIFACTS_FILE_TOOL } from "../src/mcp/file-contract"; +import { applyToolVisibility } from "../src/mcp/host-contract"; +import { ARTIFACTS_WORKSPACE_TOOLS } from "../src/mcp/workspace-contract"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { MCP_TOOLS } from "../src/mcp/tools"; import { PROJECT_ARCHIVE_SCHEMA } from "../src/project-archive-contract"; @@ -157,3 +160,12 @@ addScriptTool("app_reconcile", "Resume the current desired native Worker deploym addScriptTool("app_secrets", "Atomically set/delete app secrets (null deletes); omit secrets to list names. Values are only passed into declared native env bindings and never returned/history-exported. Existing desired code is redeployed to apply changes.", { ...appName, secrets: { type: "object", maxProperties: 32, additionalProperties: { type: ["string", "null"], maxLength: 4096 } } }, ["name"]); addScriptTool("app_move", "Move an app between authenticated private/team libraries without changing its physical app identity, resources, secrets or revisions. The former owner immediately loses future management and HTTP access.", { ...appName, library: { type: "string", enum: ["private", "team"] } }, ["name", "library"]); for (const tool of CLOUD_MCP_TOOLS.filter(tool => ["app_guide", "app_list", "app_read", "app_history"].includes(tool.name))) tool.annotations = { readOnlyHint: true }; + +for (const tool of [...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL]) if (!CLOUD_MCP_TOOLS.some(existing => existing.name === tool.name)) CLOUD_MCP_TOOLS.push(tool as Tool); +for (const tool of CLOUD_MCP_TOOLS) { + if (["artifact_write", "artifact_edit", "artifact_import", "artifact_remix", "artifact_restore"].includes(tool.name)) tool.inputSchema.properties!.preview = { type: "boolean", default: true, description: "Deliver the preview after saving and activating a valid revision. False suppresses delivery only; it does not roll back the mutation." }; + if (["artifact_request", "artifact_files", "artifact_read", "artifact_export"].includes(tool.name)) tool.inputSchema.properties!.workspace = { type: "string", minLength: 1, description: "Workspace in the authenticated library. Does not grant access." }; + if (tool.name === "artifacts_search") tool.description += " Offset is a catalog-page offset: continue next_offset even when no names match the current page."; + if (tool.name === "artifacts_mentions") tool.inputSchema.properties!.offset = { type: "integer", minimum: 0, description: "Continue the next_offset returned by a previous mention search." }; +} +applyToolVisibility(CLOUD_MCP_TOOLS); diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index 1966e56..2e39364 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -5,6 +5,7 @@ import { Miniflare } from "miniflare"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { chromium } from "playwright"; +import { ARTIFACTS_APP_URI } from "../src/mcp/app-contract"; import { ROUTING_ARTIFACT } from "../src/test/routing"; import { openGallerySubscription } from "../src/test/gallery-subscription"; @@ -135,7 +136,7 @@ test("official HTTP MCP client lists contracts, writes, edits, restores and retr expect(artifact.js).toContain("Hosted artifact"); expect(JSON.stringify(result.content)).not.toContain("__artifactsRuntime"); version = artifact.versionId; - const resource = await client.readResource({ uri: "ui://artifacts/viewer.html" }); + const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); expect(resource.contents[0]!.mimeType).toContain("text/html"); expect(payload(await client.callTool({ name: "artifact_read", arguments: { name: "overview" } })).source).toBe(source); const failedEdit = await client.callTool({ name: "artifact_edit", arguments: { name: "overview", expected_hash: "0".repeat(64), edits: [{ old_text: "Hosted artifact", new_text: "Wrong" }] } }); @@ -686,7 +687,7 @@ test("artifact files use per-artifact storage through MCP and standalone control expect((await publicRequest({ operation: "list" }, b.versionId)).status).toBe(409); const forged = await fetch(`${origin}/api/artifact/files?workspace=test`, { method: "POST", headers: { Origin: "null" }, body: JSON.stringify({ name: "files-a", request: { operation: "list" } }) }); expect(forged.status).toBe(403); - const resource = await client.readResource({ uri: "ui://artifacts/viewer.html" }); + const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); expect(JSON.stringify(resource)).toContain(`\"connectDomains\":[\"${origin}\"]`); }, 60_000); @@ -916,3 +917,38 @@ export class ArtifactServer extends DurableObject { await call("artifact_secrets", { name, secrets: { TOKEN: null } }); expect(await run(name)).toEqual({ secret: null, count: 5 }); }, 60000); + + +test("MCP workspace tools admit cross-workspace previews and resources while suppressed mutations save without delivery", async () => { + const remote = new Client({ name: "workspace-integration", version: "1" }); + const team = new Client({ name: "workspace-team-integration", version: "1" }); + await remote.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=workspace-tools`), { fetch })); + await team.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=workspace-tools&library=team`), { fetch })); + try { + const written = await remote.callTool({ name: "artifact_write", arguments: { name: "workspace-preview", slug: "workspace-preview-suppressed", contents: source, preview: false } }); + expect(written.isError).not.toBe(true); + expect(written._meta).toBeUndefined(); + const saved = written.structuredContent as { preview: { artifact: { versionId: string } } }; + const versionId = saved.preview.artifact.versionId; + const before = payload(await remote.callTool({ name: "artifact_version", arguments: { version_id: versionId } })); + expect(before.events).toEqual([]); + expect(before.compiled_id).toBeTruthy(); + expect(payload(written).liveId).toBe(versionId); + + 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" })); + const working = await client.callTool({ name: "artifacts_working", arguments: {} }); + expect((working._meta as any).workspace.items.some((item: any) => item.workspace === "workspace-tools")).toBe(false); + const mentions = await client.callTool({ name: "artifacts_mentions", arguments: { query: "WORKSPACE-PREVIEW" } }); + const uri = (mentions.structuredContent as any).items[0].uri; + const resource = await client.readResource({ uri }); + expect(JSON.parse((resource.contents[0] as { text: string }).text)).toMatchObject({ workspace: "workspace-tools", name: "workspace-preview", source }); + await expect(team.readResource({ uri })).rejects.toThrow(); + expect((await team.callTool({ name: "artifacts_preview", arguments: { workspace: "workspace-tools", version_id: versionId } })).isError).toBe(true); + 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 }); + 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(); } +}, 60_000); diff --git a/cloudflare/workspace-tools.test.ts b/cloudflare/workspace-tools.test.ts new file mode 100644 index 0000000..4f5d1c3 --- /dev/null +++ b/cloudflare/workspace-tools.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { workspaceTool } from "./workspace-tools"; +import type { GalleryData } from "../src/gallery/types"; + +test("mention search reaches matching archived artifacts after empty catalog pages", async () => { + const offsets: number[] = []; + const result = await workspaceTool({ workspace: "current", async gallery(all, offset = 0): Promise { + expect(all).toBe(true); + offsets.push(offset); + if (offset === 0) return { workspace: "current", artifacts: [{ key: "other", workspace: "current", name: "other", working: true, versions: [] }], nextOffset: 100 }; + return { workspace: "current", artifacts: [{ key: "archived", workspace: "research", name: "matching-revenue", working: false, versions: [{ id: "saved-version", revision: 3, createdAt: "2026-10-03T00:00:00Z", reason: "saved", serveCount: 1 }] }], nextOffset: null }; + } }, "artifacts_mentions", { query: "REVENUE" }); + expect(offsets).toEqual([0, 100]); + const items = result.structuredContent!.items as { uri: string; name: string }[]; + expect(items).toHaveLength(1); + expect(items[0]?.name).toBe("matching-revenue"); + expect(new URL(items[0]!.uri).searchParams.get("version_id")).toBe("saved-version"); +}); diff --git a/cloudflare/workspace-tools.ts b/cloudflare/workspace-tools.ts new file mode 100644 index 0000000..9060b8e --- /dev/null +++ b/cloudflare/workspace-tools.ts @@ -0,0 +1,25 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { GalleryData } from "../src/gallery/types"; +import { artifactResourceUri, workspaceResult } from "../src/mcp/workspace-contract"; + +/** Offsets refer to catalog pages, including pages with zero matching names. */ +export async function workspaceTool(service: { workspace: string; gallery(all: boolean, offset?: number): Promise }, name: string, args: Record): Promise { + const view = name === "artifacts_working" || args.view === "working" ? "working" : "library"; + const offset = typeof args.offset === "number" ? args.offset : 0; + const query = typeof args.query === "string" ? args.query.toLocaleLowerCase() : ""; + const gallery = await service.gallery(view === "library", offset); + const items = gallery.artifacts.filter(item => item.kind !== "script" && item.name.toLocaleLowerCase().includes(query)); + if (name === "artifacts_mentions") { + // Composer search has no pagination UI: scan catalog pages until enough + // matches exist, while repeating the gallery's permission-filtered reads. + let page = gallery; + const matches = [...items]; + while (matches.length < 100 && page.nextOffset != null) { + page = await service.gallery(true, page.nextOffset); + matches.push(...page.artifacts.filter(item => item.kind !== "script" && item.name.toLocaleLowerCase().includes(query))); + } + const links = matches.slice(0, 100).map(item => ({ type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id), name: item.name, mimeType: "application/json", description: `React artifact in workspace ${item.workspace}` })); + return { content: links, structuredContent: { items: links } }; + } + return workspaceResult({ view, workspace: gallery.workspace, items, nextOffset: gallery.nextOffset ?? null }); +} diff --git a/e2e/mcp-app-host.ts b/e2e/mcp-app-host.ts index 10226d2..7f0e9f8 100644 --- a/e2e/mcp-app-host.ts +++ b/e2e/mcp-app-host.ts @@ -11,9 +11,13 @@ type RequestBehavior = "accept" | "decline" | "throw"; declare global { interface Window { artifactAppHtml: string; + initialServerToolResult?: CallToolResult; mcpHost?: { initialized: boolean; messages: unknown[]; + modelContexts: unknown[]; + removeModelContext(): void; + sandboxReady: boolean; links: string[]; sizeChanges: Array<{ width?: number; height?: number }>; requestedModes: McpUiDisplayMode[]; @@ -32,11 +36,16 @@ declare global { const iframe = document.querySelector("#app")!; const messages: unknown[] = []; +const modelContexts: unknown[] = []; +const options = new URLSearchParams(location.search); +const minimal = options.get("host") === "minimal"; +const openai = options.get("host") === "openai"; +const proxy = options.has("proxy"); const links: string[] = []; const sizeChanges: Array<{ width?: number; height?: number }> = []; const requestedModes: McpUiDisplayMode[] = []; const serverToolCalls: Array<{ name: string; arguments?: Record }> = []; -let serverToolResult: CallToolResult = { content: [], structuredContent: { +let serverToolResult: CallToolResult = window.initialServerToolResult ?? { content: [], structuredContent: { response: { status: 200, statusText: "OK", headers: [] }, } }; let requestBehavior: RequestBehavior = "accept"; @@ -47,12 +56,13 @@ let hostContext: McpUiHostContext = { displayMode: "inline", availableDisplayModes: ["inline", "fullscreen"], containerDimensions: { width: 640, maxHeight: 600 }, + ...(options.has("deepLink") ? { "openai/deepLink": { url: options.get("deepLink")! } } : {}), }; const bridge = new AppBridge( null, { name: "Artifact browser test host", version: "1.0.0" }, - { message: { text: {} }, openLinks: {}, serverTools: {} }, + minimal ? {} : { message: { text: {} }, openLinks: {}, serverTools: {}, updateModelContext: { text: {} }, ...(openai ? { experimental: { "openai/modelContext": {} } } : {}) }, { hostContext }, ); @@ -76,6 +86,12 @@ function applyFrameSize() { window.mcpHost = { initialized: false, + sandboxReady: false, + modelContexts, + removeModelContext() { + hostContext = { ...hostContext, "openai/modelContext": null }; + bridge.setHostContext(hostContext); + }, messages, links, sizeChanges, @@ -130,6 +146,14 @@ bridge.oncalltool = async params => { serverToolCalls.push(params); return serverToolResult; }; +bridge.onupdatemodelcontext = async params => { + modelContexts.push(params); + if (!openai) return {}; + const updateId = `context-${modelContexts.length}`; + hostContext = { ...hostContext, "openai/modelContext": { ...params, updateId } }; + bridge.setHostContext(hostContext); + return { _meta: { "openai/modelContext": { updateId } } }; +}; bridge.onmessage = async params => { messages.push(params); return {}; @@ -144,4 +168,10 @@ bridge.oninitialized = () => { applyFrameSize(); await bridge.connect(new PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!)); -iframe.srcdoc = window.artifactAppHtml; +if (proxy) { + bridge.onsandboxready = async () => { + window.mcpHost!.sandboxReady = true; + await bridge.sendSandboxResourceReady({ html: window.artifactAppHtml, sandbox: "allow-scripts" }); + }; + iframe.src = "/sandbox-proxy"; +} else iframe.srcdoc = window.artifactAppHtml; diff --git a/e2e/mcp-app.test.ts b/e2e/mcp-app.test.ts index 23ec1a5..20b1276 100644 --- a/e2e/mcp-app.test.ts +++ b/e2e/mcp-app.test.ts @@ -73,6 +73,22 @@ let browser: Browser; let server: ReturnType; let hostHtml: string; const fixtureDirs = new Set(); +const transferRequests: Array<{ method: string; body: string }> = []; +const TRANSFER_FILE = { id: "one", name: "report.txt", size: 5, type: "text/plain", uploaded: "2026-10-03T00:00:00Z" }; +// A web host's outer sandbox relays JSON-RPC and loads content only after the +// SDK resource-ready handshake. The app stays in its own opaque inner frame. +const SANDBOX_PROXY_HTML = ``; async function resultFor(source: string, name: string, state: Record = {}): Promise<{ artifact: ArtifactAppPayload }> { const dir = tempDir("artifact-mcp-browser-"); @@ -88,20 +104,27 @@ async function resultFor(source: string, name: string, state: Record { +async function openHost(options: { openai?: boolean; minimal?: boolean; proxy?: boolean; deepLink?: string; result?: import("@modelcontextprotocol/sdk/types.js").CallToolResult } = {}): Promise<{ page: Page; app: FrameLocator; errors: string[]; logs: string[] }> { const page = await browser.newPage(); + page.setDefaultTimeout(5_000); + if (options.result) await page.addInitScript(result => { window.initialServerToolResult = result; }, options.result); const errors: string[] = []; const logs: string[] = []; page.on("pageerror", error => errors.push(error.message)); page.on("console", message => logs.push(`${message.type()}: ${message.text()}`)); - await page.goto(`http://127.0.0.1:${server.port}/`); + const query = new URLSearchParams(); + if (options.openai) query.set("host", "openai"); + if (options.minimal) query.set("host", "minimal"); + if (options.proxy) query.set("proxy", "1"); + if (options.deepLink) query.set("deepLink", options.deepLink); + await page.goto(`http://127.0.0.1:${server.port}/?${query}`); try { await page.waitForFunction(() => window.mcpHost?.initialized === true, undefined, { timeout: 5_000 }); } catch { const frames = await Promise.all(page.frames().map(async frame => ({ url: frame.url(), text: await frame.locator("body").innerText().catch(() => "") }))); throw new Error(`MCP App did not initialize: ${JSON.stringify({ errors, logs, frames })}`); } - return { page, app: page.frameLocator("#app"), errors, logs }; + return { page, app: options.proxy ? page.frameLocator("#app").frameLocator("#sandbox") : page.frameLocator("#app"), errors, logs }; } async function layout(app: FrameLocator) { @@ -130,14 +153,22 @@ beforeAll(async () => { const hostJs = await hostBuild.outputs[0]!.text(); const appHtml = (await artifactAppHtml()).replace( "", - ``, + ``, ); hostHtml = ``; server = Bun.serve({ hostname: "127.0.0.1", port: 0, - fetch() { - return new Response(hostHtml, { headers: { "content-type": "text/html; charset=utf-8" } }); + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/sandbox-proxy") return new Response(SANDBOX_PROXY_HTML, { headers: { "content-type": "text/html" } }); + if (url.pathname.startsWith("/api/artifact/files/transfer/")) { + const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "PUT, GET, OPTIONS", "access-control-allow-headers": "content-type" }; + if (request.method === "OPTIONS") return new Response(null, { headers }); + transferRequests.push({ method: request.method, body: await request.text() }); + return Response.json({ file: TRANSFER_FILE }, { headers }); + } + return new Response(hostHtml.replaceAll("__artifact_transfer_origin__", url.origin), { headers: { "content-type": "text/html; charset=utf-8" } }); }, }); browser = await chromium.launch({ headless: true }); @@ -237,7 +268,7 @@ test("renders and replaces interactive artifacts through the MCP Apps bridge", a })); await app.getByRole("status").waitFor(); expect(await app.getByRole("status").textContent()).toBe("Compilation failed"); - expect(await app.getByRole("heading", { name: "Replacement artifact" }).count()).toBe(0); + expect(await app.getByRole("heading", { name: "Replacement artifact" }).count()).toBe(1); expect(errors).toEqual([]); await page.close(); }, 30_000); @@ -433,7 +464,8 @@ test.each([false, true])("routes server requests for cached legacy runtime=%s on const calls = await page.evaluate(() => window.mcpHost!.serverToolCalls); expect(calls).toHaveLength(1); expect(calls[0]?.name).toBe("artifact_request"); - expect(Object.keys(calls[0]?.arguments ?? {}).sort()).toEqual(["request", "version_id"]); + expect(Object.keys(calls[0]?.arguments ?? {}).sort()).toEqual(["request", "version_id", "workspace"]); + expect(calls[0]?.arguments?.workspace).toBe(meta.artifact.workspace); expect(calls[0]?.arguments?.version_id).toBe(meta.artifact.versionId); const request = calls[0]?.arguments?.request as { path: string; method: string; headers: [string, string][]; body: string; @@ -541,14 +573,14 @@ export default function Artifact() { await app.getByRole("button", { name: "List files" }).click(); await app.getByText("report.csv", { exact: true }).waitFor(); expect(await page.evaluate(() => window.mcpHost!.serverToolCalls.map(({ name, arguments: args }) => ({ name, arguments: args })))).toEqual([ - { name: "artifact_files", arguments: { version_id: meta.artifact.versionId, request: { operation: "list" } } }, + { name: "artifact_files", arguments: { version_id: meta.artifact.versionId, workspace: meta.artifact.workspace, request: { operation: "list" } } }, ]); const url = `https://artifact.example/api/${legacy ? "canvas" : "artifact"}/files/transfer/${"a".repeat(64)}/12345678-1234-1234-1234-123456789abc`; await page.evaluate(({ file, url }) => window.mcpHost!.setServerToolResult({ content: [], structuredContent: { result: { file, url, expires: "2026-09-07T00:05:00Z" } } }), { file, url }); await app.getByRole("button", { name: "Download file" }).click(); await app.getByText("downloaded", { exact: true }).waitFor(); expect(await page.evaluate(() => window.mcpHost!.links)).toEqual([url]); - expect(await page.evaluate(() => { const call = window.mcpHost!.serverToolCalls[1]!; return { name: call.name, arguments: call.arguments }; })).toEqual({ name: "artifact_files", arguments: { version_id: meta.artifact.versionId, request: { operation: "download", id: "one" } } }); + expect(await page.evaluate(() => { const call = window.mcpHost!.serverToolCalls[1]!; return { name: call.name, arguments: call.arguments }; })).toEqual({ name: "artifact_files", arguments: { version_id: meta.artifact.versionId, workspace: meta.artifact.workspace, request: { operation: "download", id: "one" } } }); await page.evaluate(() => window.mcpHost!.setServerToolResult({ content: [{ type: "text", text: "File access denied" }], isError: true })); await app.getByRole("button", { name: "List files" }).click(); await app.getByText("File access denied", { exact: true }).waitFor(); @@ -586,3 +618,160 @@ test("cached Canvas runtime receives state and themes and unmounts when replaced expect(errors).toEqual([]); await page.close(); }, 30000); + + +const CONTEXT_ARTIFACT = `import { Button, H1, useArtifactContext, useHostEnvironment, getArtifactCapabilities } from "sidequery/artifacts"; +export default function ContextArtifact() { + const context = useArtifactContext(); + const environment = useHostEnvironment(); + const capabilities = getArtifactCapabilities(); + return <>

Context artifact

Attached: {String(context.attached)}

Selected: {String(context.context?.selection?.row ?? "none")}

Locale: {environment.locale ?? "unset"}

Timezone: {environment.timeZone ?? "unset"}

Capabilities: {JSON.stringify(capabilities.actions)}

Model context: {String(capabilities.modelContext)}

Backend capabilities: {String(capabilities.server)}/{String(capabilities.files)}/{String(capabilities.plugins)}

; +}`; + +test("OpenAI model context reconciles user removal and remount without sending a prompt", async () => { + const meta = await resultFor(CONTEXT_ARTIFACT, "context"); + meta.artifact.workspace = "analysis"; + const { page, app, errors } = await openHost({ openai: true }); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByText("Attached: false", { exact: true }).waitFor(); + await app.getByRole("button", { name: "Attach selection" }).click(); + await app.getByText("Attached: true", { exact: true }).waitFor(); + await app.getByText("Selected: 42", { exact: true }).waitFor(); + const updates = await page.evaluate(() => window.mcpHost!.modelContexts); + expect(updates).toHaveLength(1); + expect(JSON.stringify(updates[0])).toContain(meta.artifact.versionId); + expect(JSON.stringify(updates[0])).toContain('"row":42'); + expect(JSON.stringify(updates[0])).toContain('"workspace":"analysis"'); + expect(await page.evaluate(() => window.mcpHost!.messages)).toEqual([]); + await page.evaluate(meta => window.mcpHost!.setServerToolResult({ content: [], _meta: meta }), meta); + await page.evaluate(() => window.mcpHost!.sendResult({ content: [], _meta: { workspace: { view: "working", workspace: "analysis", items: [], nextOffset: null } } })); + await app.getByText("Attached: true", { exact: true }).waitFor(); + expect(await page.evaluate(() => window.mcpHost!.modelContexts.length)).toBe(1); + await page.evaluate(() => window.mcpHost!.removeModelContext()); + await app.getByText("Attached: false", { exact: true }).waitFor(); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByText("Attached: false", { exact: true }).waitFor(); + expect(await page.evaluate(() => window.mcpHost!.modelContexts.length)).toBe(1); + await page.evaluate(() => window.mcpHost!.configure({ locale: "fr-FR", timeZone: "Europe/Paris", styles: { variables: { "--color-text-primary": "rgb(10, 20, 30)" } } })); + await app.getByText("Locale: fr-FR", { exact: true }).waitFor(); + await app.getByText("Timezone: Europe/Paris", { exact: true }).waitFor(); + expect(await app.locator("html").getAttribute("lang")).toBe("fr-FR"); + expect(await app.locator("html").evaluate(element => element.style.getPropertyValue("--color-text-primary"))).toBe("rgb(10, 20, 30)"); + expect(errors).toEqual([]); +}, 30_000); + +test("missing host capabilities leave ordinary rendering usable and disable context and actions", async () => { + const meta = await resultFor(CONTEXT_ARTIFACT, "no-capabilities"); + meta.artifact.server = meta.artifact.files = meta.artifact.plugins = true; + const { page, app, errors } = await openHost({ minimal: true }); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByRole("heading", { name: "Context artifact" }).waitFor(); + expect(await app.getByRole("button", { name: "Attach selection" }).isDisabled()).toBe(true); + await app.getByText('Capabilities: {"openUrl":false,"promptAgent":false,"openFile":false}', { exact: true }).waitFor(); + await app.getByText("Model context: false", { exact: true }).waitFor(); + await app.getByText("Backend capabilities: false/false/false", { exact: true }).waitFor(); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls)).toEqual([]); + expect(errors).toEqual([]); +}, 30_000); + +test("workspace entrypoints search, paginate an empty match page, and open an authenticated revision", async () => { + const meta = await resultFor(INTERACTIVE_ARTIFACT, "chosen"); + meta.artifact.workspace = "research"; + const { page, app, errors, logs } = await openHost(); + const workspace = { view: "library" as const, workspace: "conversation", items: [], nextOffset: 100 }; + await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace: { ...workspace, view: "working" } } }), workspace); + await app.getByRole("heading", { name: "Working artifacts", exact: true }).waitFor(); + await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), workspace); + await app.getByRole("heading", { name: "Artifacts", exact: true }).waitFor(); + await app.getByLabel("Search artifacts").fill("chosen"); + await page.evaluate(workspace => window.mcpHost!.setServerToolResult({ content: [], _meta: { workspace } }), workspace); + await app.getByRole("button", { name: "Search", exact: true }).click(); + await page.waitForFunction(() => window.mcpHost!.serverToolCalls.length === 1).catch(error => { throw new Error(`${error}: ${JSON.stringify(logs)}`); }); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls[0])).toMatchObject({ name: "artifacts_search", arguments: { query: "chosen", view: "library", offset: 0 } }); + const next = { ...workspace, nextOffset: null, items: [{ name: "chosen", workspace: "research", working: true, versions: [{ id: meta.artifact.versionId, revision: 1, createdAt: "2026-10-03T00:00:00Z" }] }] }; + await page.evaluate(workspace => window.mcpHost!.setServerToolResult({ content: [], _meta: { workspace } }), next); + await app.getByRole("button", { name: "Load more" }).click(); + await app.getByRole("button", { name: "Open chosen" }).waitFor(); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls[1])).toMatchObject({ name: "artifacts_search", arguments: { offset: 100 } }); + await app.getByLabel("Revision of chosen").selectOption(meta.artifact.versionId); + await page.evaluate(meta => window.mcpHost!.setServerToolResult({ content: [], _meta: meta }), meta); + await app.getByRole("button", { name: "Open chosen" }).click(); + await app.getByRole("heading", { name: "Interactive artifact" }).waitFor(); + expect(await page.evaluate(() => window.mcpHost!.serverToolCalls[2])).toMatchObject({ name: "artifacts_preview", arguments: { workspace: "research", version_id: meta.artifact.versionId } }); + expect(errors).toEqual([]); +}, 30_000); + +test("OpenAI deep links select a revision through the server before entering its internal route", async () => { + const { ROUTING_ARTIFACT } = await import("../src/test/routing"); + const meta = await resultFor(ROUTING_ARTIFACT, "linked"); + meta.artifact.workspace = "research"; + const query = new URLSearchParams({ workspace: "research", name: "linked", version_id: meta.artifact.versionId, route: "/accounts/456?tab=details" }); + const { page, app, errors } = await openHost({ openai: true, deepLink: `/artifact?${query}`, result: { content: [], _meta: meta } }); + await app.getByRole("heading", { name: "Account 456" }).waitFor(); + await app.getByText("Tab: details", { exact: true }).waitFor(); + const calls = await page.evaluate(() => window.mcpHost!.serverToolCalls); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ name: "artifacts_preview", arguments: { workspace: "research", version_id: meta.artifact.versionId } }); + expect(errors).toEqual([]); +}, 30_000); + +test("web sandbox proxy blocks unlisted network origins and permits granted artifact file transfers", async () => { + const source = `import { Button, artifactFiles, useState } from "sidequery/artifacts"; +export default function Transfer() { const [result,setResult]=useState("idle"); return <>

{result}

; }`; + const meta = await resultFor(source, "transfer"); + meta.artifact.files = true; + const { page, app, errors, logs } = await openHost({ proxy: true }); + const url = `http://127.0.0.1:${server.port}/api/artifact/files/transfer/${"a".repeat(64)}/12345678-1234-1234-1234-123456789abc`; + await page.evaluate(({ file, url }) => window.mcpHost!.setServerToolResult({ content: [], structuredContent: { result: { file, url, expires: "2099-01-01T00:00:00Z" } } }), { file: TRANSFER_FILE, url }); + await page.evaluate(meta => window.mcpHost!.sendResult({ content: [], _meta: meta }), meta); + await app.getByRole("button", { name: "Upload report" }).click(); + await app.getByText("Uploaded report.txt", { exact: true }).waitFor(); + expect(transferRequests).toContainEqual({ method: "PUT", body: "hello" }); + expect(await page.evaluate(() => window.mcpHost!.sandboxReady)).toBe(true); + const blocked = await app.locator("body").evaluate(async () => { + try { await fetch("https://example.com/forbidden"); return false; } catch { return true; } + }); + expect(blocked).toBe(true); + expect(logs.some(message => message.includes("connect-src") && message.includes("example.com/forbidden"))).toBe(true); + expect(errors).toEqual([]); +}, 30_000); + + +test("compatible refresh preserves route, session state and selected context while diagnostics keep the view mounted", async () => { + const source = `import { Button, Link, useLocation, useArtifactState, useArtifactContext } from "sidequery/artifacts"; +export default function RefreshArtifact() { + const [count, setCount] = useArtifactState("count", 0); + const context = useArtifactContext(); + const location = useLocation(); + return <>

Refresh artifact

Count: {count}

Route: {location.pathname}

Selected: {String(context.context?.selection?.row ?? "none")}

Details; +}`; + const meta = await resultFor(source, "refresh"); + meta.artifact.workspace = "research"; + const { page, app, errors } = await openHost({ openai: true }); + const workspace = { view: "library", workspace: "conversation", nextOffset: null, items: [{ name: "refresh", workspace: "research", working: true, versions: [] }] }; + await page.evaluate(workspace => window.mcpHost!.sendResult({ content: [], _meta: { workspace } }), workspace); + await page.evaluate(meta => window.mcpHost!.setServerToolResult({ content: [], _meta: meta }), meta); + await app.getByRole("button", { name: "Open refresh", exact: true }).click(); + await app.getByRole("button", { name: "Increment", exact: true }).click(); + await app.getByRole("link", { name: "Details", exact: true }).click(); + await app.getByRole("button", { name: "Attach row", exact: true }).click(); + await app.getByText("Selected: 42", { exact: true }).waitFor(); + await app.getByText("Route: /details", { exact: true }).waitFor(); + await app.getByText("Count: 1", { exact: true }).waitFor(); + const previousHeading = await app.getByRole("heading", { name: "Refresh artifact", exact: true }).elementHandle(); + await app.getByRole("button", { name: "Refresh artifact", exact: true }).click(); + await previousHeading!.waitForElementState("hidden"); + await page.waitForFunction(() => window.mcpHost!.serverToolCalls.length === 2); + await app.getByText("Selected: 42", { exact: true }).waitFor(); + await app.getByText("Route: /details", { exact: true }).waitFor(); + await app.getByText("Count: 1", { exact: true }).waitFor(); + await page.evaluate(() => window.mcpHost!.sendResult({ content: [{ type: "text", text: "Saved without preview" }], structuredContent: { applied: true, ok: true, revision: 2 } })); + await app.getByText("Count: 1", { exact: true }).waitFor(); + await page.evaluate(() => window.mcpHost!.sendResult({ content: [{ type: "text", text: "Type error in new draft" }], isError: true })); + await app.getByRole("status").filter({ hasText: "Type error in new draft" }).waitFor(); + await app.getByText("Selected: 42", { exact: true }).waitFor(); + await app.getByText("Route: /details", { exact: true }).waitFor(); + await app.getByText("Count: 1", { exact: true }).waitFor(); + expect(await page.evaluate(() => window.mcpHost!.modelContexts.length)).toBe(1); + expect(errors).toEqual([]); +}, 30_000); diff --git a/e2e/mcp-file-editor.test.ts b/e2e/mcp-file-editor.test.ts new file mode 100644 index 0000000..42d7b3b --- /dev/null +++ b/e2e/mcp-file-editor.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { chromium, type Browser } from "playwright"; + +test("owned file editor saves inside an allow-scripts sandbox and preserves conflicts", async () => { + const directory = await mkdtemp(join(import.meta.dir, ".file-editor-test-")); + let server: ReturnType | undefined; + let browser: Browser | undefined; + try { + const entrypoint = join(directory, "entry.tsx"); + await Bun.write(entrypoint, ` + import { mountArtifactFileEditor } from '../../src/runtime/mcp-file-editor'; + const uri = 'host-owned:opaque-token'; + let text = 'export default "initial";'; + let etag = 'v1'; + window.fileWrites = []; + window.fileOutcome = 'saved'; + const resources = { + addUpdateHandler(handler) { window.fileUpdate = () => handler({params:{uri}}); return () => {}; }, + async subscribe() { return {}; }, async unsubscribe() { return {}; }, + async read() { return {contents:[{uri,text,openaiMetadata:{writable:true,etag}}]}; }, + async write(writeUri, value) { + window.fileWrites.push({uri:writeUri,...value}); + if (window.fileOutcome === 'conflict') return {outcome:'conflict',etag:'external'}; + text = value.text; etag = 'v2'; return {outcome:'saved',etag}; + } + }; + window.fileEditor = mountArtifactFileEditor(document.getElementById('root'), {resources}, {file:{name:'demo.artifact.tsx',resourceUri:uri}}); + `); + const build = await Bun.build({ entrypoints: [entrypoint], target: "browser" }); + expect(build.success).toBe(true); + const js = await build.outputs[0]!.text(); + server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: request => { + const path = new URL(request.url).pathname; + if (path === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript", "access-control-allow-origin": "*" } }); + return new Response(path === "/editor" ? '
' : '', { headers: { "content-type": "text/html" } }); + } }); + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await page.goto(server.url.href); + const frame = page.frameLocator('iframe'); + const editor = frame.getByRole("textbox", { name: "demo.artifact.tsx", exact: true }); + await editor.waitFor(); + const save = frame.getByRole("button", { name: "Save", exact: true }); + expect(await save.isDisabled()).toBe(true); + await editor.fill('export default "changed";'); + await save.click(); + await frame.getByText("Saved.", { exact: true }).waitFor(); + const sandbox = page.frames().find(value => value.url().endsWith("/editor"))!; + expect(await sandbox.evaluate(() => (window as any).fileWrites)).toEqual([{ uri: "host-owned:opaque-token", text: 'export default "changed";', ifMatch: "v1" }]); + await editor.fill('export default "keyboard";'); + await editor.press("ControlOrMeta+s"); + await frame.getByText("Saved.", { exact: true }).waitFor(); + expect(await sandbox.evaluate(() => (window as any).fileWrites.length)).toBe(2); + await sandbox.evaluate(() => { (window as any).fileOutcome = "conflict"; }); + await editor.fill('export default "my draft";'); + await save.click(); + await frame.getByText(/The file changed in the host/).waitFor(); + expect(await editor.innerText()).toContain("my draft"); + expect(await save.isDisabled()).toBe(true); + await frame.getByRole("button", { name: "Discard draft and reload", exact: true }).click(); + await frame.getByText("No unsaved changes", { exact: true }).waitFor(); + expect(await editor.innerText()).toContain("keyboard"); + await sandbox.evaluate(() => (window as any).fileEditor.dispose()); + expect(await frame.getByRole("textbox").count()).toBe(0); + } finally { + await browser?.close(); + server?.stop(true); + await rm(directory, { recursive: true, force: true }); + } +}, 60000); diff --git a/package.json b/package.json index 0dfbc88..eab43b1 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,14 @@ "src/gallery/Geist-OFL.txt", "src/gallery/subscription.ts", "src/gallery/local-subscriptions.ts", - "src/gallery/folders.tsx" + "src/gallery/folders.tsx", + "src/mcp/host-contract.ts", + "src/mcp/workspace-contract.ts", + "src/mcp/file-contract.ts", + "src/mcp/local-workspace.ts", + "src/runtime/mcp-host.ts", + "src/runtime/mcp-workspace.ts", + "src/runtime/mcp-file-editor.tsx" ], "publishConfig": { "access": "public" @@ -132,7 +139,7 @@ "test:native-worker": "tsc -p scripts/native-worker/tsconfig.json --noEmit && bun test scripts/native-worker --timeout 30000", "native-worker:operator": "bun run scripts/native-worker/operator.ts", "test:e2e": "bun test e2e/herdr.e2e.test.ts", - "test:mcp-ui": "bun test e2e/gallery-transfer.test.ts e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-secrets.test.ts e2e/gallery-native-apps.test.ts e2e/gallery-navigation.test.ts e2e/gallery-live.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", + "test:mcp-ui": "bun test e2e/gallery-transfer.test.ts e2e/gallery-onboarding.test.ts e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/mcp-file-editor.test.ts e2e/gallery-scripts.test.ts e2e/gallery-secrets.test.ts e2e/gallery-native-apps.test.ts e2e/gallery-navigation.test.ts e2e/gallery-live.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", "typecheck": "tsc --noEmit && tsc -p examples/runner-status/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.local.json --noEmit", "dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local", "prepare:celld": "bun run scripts/prepare-celld.ts", @@ -163,7 +170,8 @@ "@codemirror/state": "^6.7.4", "@codemirror/view": "^6.43.11", "@lezer/highlight": "^1.2.3", - "@modelcontextprotocol/ext-apps": "^1.7.5", + "@modelcontextprotocol/ext-apps": "1.7.5", + "@openai/mcp-extensions": "0.1.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "better-auth": "1.7.3", diff --git a/scripts/package.integration.test.ts b/scripts/package.integration.test.ts index 3176765..2849746 100644 --- a/scripts/package.integration.test.ts +++ b/scripts/package.integration.test.ts @@ -211,7 +211,7 @@ test("published tarball runs the CLI, gallery, and stdio MCP outside a checkout" await Bun.write(mcpSmoke, ` import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -const client = new Client({ name: "package-smoke", version: "1" }); +const client = new Client({ name: "package-smoke", version: "1" }, { capabilities: { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } } } }); const transport = new StdioClientTransport({ command: ${JSON.stringify(artifact)}, args: ["mcp", "--dir", ${JSON.stringify(artifacts)}, "--history-db", ${JSON.stringify(history)}], diff --git a/scripts/prepare-cloudflare.ts b/scripts/prepare-cloudflare.ts index 15d8fa5..81df2b3 100644 --- a/scripts/prepare-cloudflare.ts +++ b/scripts/prepare-cloudflare.ts @@ -83,7 +83,7 @@ await writeFile(join(dirname(output), "gallery-request.json"), JSON.stringify(aw // Compile trusted schemas at build time: Workers must not need eval() to // validate arguments, and the listed schema stays the validation source. const ajv = new Ajv({ allErrors: true, strict: false, code: { source: true, esm: true } }); -for (const tool of CLOUD_MCP_TOOLS) ajv.addSchema(tool.inputSchema, tool.name); +for (const tool of CLOUD_MCP_TOOLS) ajv.addSchema(structuredClone(tool.inputSchema), tool.name); await writeFile(join(dirname(output), "tool-validators.js"), standaloneCode(ajv, Object.fromEntries(CLOUD_MCP_TOOLS.map(tool => [tool.name, tool.name])))); await writeFile(join(dirname(output), "tool-validators.d.ts"), 'import type { ValidateFunction } from "ajv";\n' + CLOUD_MCP_TOOLS.map(tool => `export const ${tool.name}: ValidateFunction;`).join("\n")); console.log(`Prepared ${Object.keys(files).length} compiler files: ${relative(root, output)}`); diff --git a/src/mcp/app-contract.ts b/src/mcp/app-contract.ts index 2270f3f..19a212d 100644 --- a/src/mcp/app-contract.ts +++ b/src/mcp/app-contract.ts @@ -1,9 +1,13 @@ -export const ARTIFACTS_APP_URI = "ui://artifacts/viewer.html"; +// Bump this resource version for incompatible shell or payload changes. Hosts cache by URI. +export const ARTIFACTS_APP_URI = "ui://artifacts/v2/viewer.html"; export const ARTIFACTS_APP_MIME = "text/html;profile=mcp-app"; export const ARTIFACTS_APP_META = { ui: { resourceUri: ARTIFACTS_APP_URI } }; export const ARTIFACTS_RESOURCE = { uri: ARTIFACTS_APP_URI, name: "Artifacts", mimeType: ARTIFACTS_APP_MIME, - _meta: { ui: { prefersBorder: false, csp: { connectDomains: [], resourceDomains: [] } } }, + _meta: { + ui: { prefersBorder: false, csp: { connectDomains: [], resourceDomains: [] } }, + "openai/ui": { availableDisplayModes: ["inline", "fullscreen"] }, + }, }; export type ArtifactAppPayload = { @@ -11,4 +15,6 @@ export type ArtifactAppPayload = { js: string; state: Record; server?: boolean; plugins?: boolean; files?: boolean; + workspace?: string; + revision?: number; }; diff --git a/src/mcp/app.test.ts b/src/mcp/app.test.ts index 47a4552..562f7b5 100644 --- a/src/mcp/app.test.ts +++ b/src/mcp/app.test.ts @@ -91,7 +91,7 @@ test("working preview initializes from sidecar without modifying it and rejects test("official MCP client can initialize, discover UI, create and show over standard stdio", async () => { const dir = tempDir(); - const client = new Client({ name: "artifact-test", version: "1" }); + const client = new Client({ name: "artifact-test", version: "1" }, { capabilities: { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [ARTIFACTS_APP_MIME] } } } }); const transport = new StdioClientTransport({ command: process.execPath, args: ["run", join(import.meta.dir, "../cli.ts"), "mcp", "--dir", dir, "--history-db", join(dir, "history.sqlite")], @@ -104,7 +104,7 @@ test("official MCP client can initialize, discover UI, create and show over stan for (const name of ["artifact_write", "artifact_open", "artifact_edit", "artifact_restore"]) { expect(tools.tools.find(tool => tool.name === name)?._meta).toMatchObject({ ui: { resourceUri: ARTIFACTS_APP_URI } }); } - expect(tools.tools.find(tool => tool.name === "artifact_list")?._meta).toBeUndefined(); + expect(tools.tools.find(tool => tool.name === "artifact_list")?._meta).toMatchObject({ ui: { visibility: ["model", "app"] } }); const resources = await client.listResources(); expect(resources.resources[0]?.mimeType).toBe(ARTIFACTS_APP_MIME); const resource = await client.readResource({ uri: ARTIFACTS_APP_URI }); diff --git a/src/mcp/app.ts b/src/mcp/app.ts index 7cfebbe..0c5154d 100644 --- a/src/mcp/app.ts +++ b/src/mcp/app.ts @@ -29,6 +29,8 @@ html,body{margin:0;font-family:system-ui,sans-serif;overflow:hidden} html,body{background:transparent} body{color:var(--artifact-foreground,#f0f0f0)} #artifact-shell{position:relative;isolation:isolate;display:flex;flex-direction:column;max-height:min(var(--artifact-inline-limit,600px),100vh);max-height:min(var(--artifact-inline-limit,600px),100dvh);height:var(--artifact-fixed-height,auto);min-width:0} + .artifact-controls{display:flex;gap:8px;flex-wrap:wrap;padding:12px} +.artifact-controls[hidden]{display:none} #artifact-toolbar{position:absolute;top:8px;right:8px;z-index:1} #artifact-toolbar[hidden]{display:none} #display-mode{display:grid;place-items:center;width:44px;height:44px;padding:0;border:0;border-radius:12px;background:color-mix(in srgb,var(--artifact-background,#181818) 72%,transparent);color:inherit;box-shadow:0 1px 4px #0002,inset 0 0 0 1px color-mix(in srgb,currentColor 12%,transparent);backdrop-filter:blur(12px);cursor:pointer} @@ -56,7 +58,7 @@ html[data-display-mode="fullscreen"] #root,html[data-display-mode="pip"] #root{m } /** Compile a source snapshot without a loopback server or a Herdr process. */ -export async function artifactAppResult(service: ArtifactService, selection: { name?: string; version_id?: string; event_id?: string }) { +export async function artifactAppResult(service: ArtifactService, selection: { name?: string; version_id?: string; event_id?: string }, deliver = true) { if (Boolean(selection.name) === Boolean(selection.version_id)) throw new Error("provide name or version_id, but not both"); if (selection.event_id && !selection.version_id) throw new Error("event_id requires version_id"); const saved = selection.version_id ? service.version(selection.version_id) : undefined; @@ -83,11 +85,11 @@ export async function artifactAppResult(service: ArtifactService, selection: { n try { const version = saved ?? history.capture({ workspace: service.artifactsDir, name: artifactIdFromFile(path), sourcePath: path, source, project, runtime }); // Delivery records a preview event, not evidence that a human saw the app. - const eventId = history.served(version.id, state, "preview", service.env, runtime); + const eventId = deliver ? history.served(version.id, state, "preview", service.env, runtime) : undefined; return { ok: true, path, check: formatArtifactCheck([]), diagnostics: [], artifact: { name: version.name, versionId: version.id, eventId, sourceHash: version.source_hash }, - _meta: { artifact: { name: version.name, versionId: version.id, eventId, sourceHash: version.source_hash, js: compiled.js, state } satisfies ArtifactAppPayload }, + _meta: eventId ? { artifact: { name: version.name, versionId: version.id, eventId, sourceHash: version.source_hash, js: compiled.js, state, workspace: service.artifactsDir } satisfies ArtifactAppPayload } : undefined, }; } finally { history.close(); } } diff --git a/src/mcp/file-contract.ts b/src/mcp/file-contract.ts new file mode 100644 index 0000000..9624d14 --- /dev/null +++ b/src/mcp/file-contract.ts @@ -0,0 +1,42 @@ +import { OpenAIFileEntrypointInputSchema } from "@openai/mcp-extensions/server"; +import { ARTIFACTS_APP_URI } from "./app-contract"; + +/** Open only our owned format; ordinary TSX remains with the host's normal viewer. */ +export const ARTIFACTS_FILE_TOOL = { + name: "artifacts_file", + title: "Artifact source editor", + description: "View or explicitly edit an owned .artifact.tsx file using host-managed resource access. Opening a file does not execute, import, or publish its source.", + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + inputSchema: { + type: "object", + properties: { + file: { + type: "object", + properties: { + name: { type: "string", minLength: 1, pattern: "\\.artifact\\.tsx$" }, + resourceUri: { type: "string", minLength: 1 }, + }, + required: ["name", "resourceUri"], + additionalProperties: false, + }, + }, + required: ["file"], + additionalProperties: false, + }, + _meta: { + ui: { resourceUri: ARTIFACTS_APP_URI, visibility: ["app"] }, + "openai/ui": { entrypoints: [{ type: "file", extensions: [".artifact.tsx"] }] }, + }, +}; + +/** The server validates the envelope, but never resolves this opaque URI as a path. */ +export function artifactFileResult(args: unknown) { + const input = OpenAIFileEntrypointInputSchema.parse(args); + if (!input.file.name.endsWith(".artifact.tsx") || !input.file.resourceUri.trim()) { + throw new Error("A .artifact.tsx filename and nonblank host resource URI are required."); + } + return { + content: [{ type: "text" as const, text: `Opened ${input.file.name} in the artifact source editor.` }], + structuredContent: { file: input.file }, + }; +} diff --git a/src/mcp/guide.ts b/src/mcp/guide.ts index b8ab7e7..3622812 100644 --- a/src/mcp/guide.ts +++ b/src/mcp/guide.ts @@ -6,7 +6,7 @@ export const ARTIFACTS_GUIDE_EXPORTS = [ "Pill", "Row", "Select", "Spacer", "Stack", "Stat", "Table", "Text", "TextArea", "TextInput", "Toggle", "artifactFetch", "pluginCall", "Routes", "Route", "Outlet", "Navigate", "NavLink", "useNavigate", "useParams", "useLocation", "useSearchParams", "useMatch", "useResolvedPath", "artifactPaletteDark", "artifactPaletteLight", "artifactTypography", "mergeStyle", "themeFromKind", "tokensFromPalette", "useArtifactAction", "useArtifactState", "useState", "useReducer", "useRef", "useMemo", "useCallback", "useEffect", "useHostTheme", - "artifactFiles", "MAX_ARTIFACTS_FILE_BYTES", "getArtifactCapabilities", + "artifactFiles", "MAX_ARTIFACTS_FILE_BYTES", "getArtifactCapabilities", "updateArtifactContext", "useArtifactContext", "useHostEnvironment", ]; export const LEGACY_GUIDE_EXPORTS = [ "useCanvasAction", "useCanvasState", "canvasFetch", "canvasFiles", "MAX_CANVAS_FILE_BYTES", @@ -32,7 +32,10 @@ Historical sources can still use these compatibility aliases: ${LEGACY_GUIDE_EXP - useState, useReducer, useRef, useMemo, useCallback, useEffect: standard React hooks for component-local state and behavior. - useArtifactState(key: string, defaultValue: T): [T, setter] for host-backed state. Keys must be stable and distinct. The setter accepts a value or updater. Live local views persist through the host; MCP and gallery views keep isolated in-memory state. - useHostTheme(): returns the host theme with color and typography tokens. -- useArtifactAction(): returns a dispatcher accepting {type: "openFile", path, selection?}, {type: "promptAgent", prompt}, or {type: "openUrl", url}. In MCP Apps, promptAgent and HTTP(S) openUrl depend on host approval; openFile is unavailable. +- useHostEnvironment(): returns host locale/timeZone when supplied. +- updateArtifactContext({route?, selection?, filters?} | null): explicitly replace the compact model context (maximum 16 KiB). The MCP bridge adds artifact identity and revision. This does not prompt the agent. null clears the app context. +- useArtifactContext(): returns {context, attached, update}; attachment removal and host changes update the hook. Treat it as a user-selected snapshot, not durable app data. +- useArtifactAction(): returns a dispatcher accepting {type: "openFile", path, selection?}, {type: "promptAgent", prompt}, or {type: "openUrl", url}. In MCP Apps, promptAgent and HTTP(S) openUrl depend on host approval; openFile requires the optional host file extension. ## Deployment plugins @@ -40,7 +43,7 @@ pluginCall(plugin, operation, input, { signal }?) calls an authenticated depl ## Routing -The runtime supplies React Router. Use Routes, Route, Outlet, Navigate, NavLink and routing hooks from sidequery/artifacts. Link to="/accounts/123" navigates within an artifact; existing Link href="https://..." keeps ordinary link behavior. Nested layouts, params, search params and navigation state work normally. Standalone /slug/* URLs support deep links, reload and browser back/forward. Gallery and chat views keep navigation in memory. /api and /api/* are reserved for the artifact backend; /_artifact/* is reserved for runtime bridges. +The runtime supplies React Router. Use Routes, Route, Outlet, Navigate, NavLink and routing hooks from sidequery/artifacts. Link to="/accounts/123" navigates within an artifact; existing Link href="https://..." keeps ordinary link behavior. Nested layouts, params, search params and navigation state work normally. Standalone /slug/* URLs support deep links, reload and browser back/forward. Gallery and chat views keep navigation in memory. MCP host deep links may initialize an internal route; explicit working-view refresh preserves compatible routes and session state. /api and /api/* are reserved for the artifact backend; /_artifact/* is reserved for runtime bridges. ## Hosted servers and SQLite @@ -48,7 +51,7 @@ artifactFetch(path: string, init?: RequestInit): Promise sends a reque Server requests require a hosted runtime with a native artifacts server. Local Bun CLI, local stdio MCP and local gallery views do not execute servers and report requests as unavailable. Hosted MCP Apps and the hosted gallery support them. Hosted artifact_write accepts server TypeScript exporting class ArtifactServer extends DurableObject from "cloudflare:workers", alongside browser contents. -getArtifactCapabilities() returns {server, files, plugins, hostActions, statePersistence} for the current view without making requests. The booleans describe installed bridges; individual operations still follow the current access policy. statePersistence is "persistent" when UI state is saved through the host, otherwise "session". Gallery and MCP UI state is temporary; use server SQLite for durable app data. Query capabilities before showing controls that require server, file, plugin or host-action support. +getArtifactCapabilities() returns {server, files, plugins, hostActions, actions: {openUrl, promptAgent, openFile}, modelContext, statePersistence} for the current view without making requests. The booleans describe installed bridges; individual operations still follow the current access policy. statePersistence is "persistent" when UI state is saved through the host, otherwise "session". Gallery and MCP UI state is temporary; use server SQLite for durable app data. Query capabilities before showing controls that require server, file, plugin or host-action support. Each artifact with a server gets one native SQLite database. Different artifact names get separate databases; multiple tabs and server restarts use the same database. Use this.ctx.storage.sql.exec(sql, ...bindings) with ? placeholders for values; cursors support .toArray() and .one(). Use this.ctx.storage.kv.get/put/delete for key/value data and this.ctx.storage.transactionSync(() => { ... }) for synchronous SQL/KV transactions. Initialize tables with create table if not exists. @@ -81,7 +84,7 @@ Files use the same library + workspace + artifact identity as the database and s For browser TSX, embed data in the source or use artifactFetch when a hosted native server is available. Direct network calls (fetch, XMLHttpRequest, WebSocket), eval, new Function, process/Bun APIs, localStorage and sessionStorage are disallowed. The browser import restrictions above do not apply to native server source, which imports DurableObject from "cloudflare:workers". -artifact_write creates or replaces the full source and then validates it. artifact_edit applies exact replacements and then validates; use artifact_read's source_hash as expected_hash to guard edits. A failed typecheck can leave source applied: inspect applied/ok and diagnostics, fix the source, and validate again. Do not treat an error as a rollback. Successful writes/edits show an inline preview in MCP Apps hosts. artifact_typecheck and artifact_compile check existing artifacts; artifact_open shows one. artifact_history lists revisions; artifact_version reads archived source; artifact_restore restores source as a new revision while retaining current state. Hosted revisions include both client and server source. artifact_export returns a complete version 1 project archive including helpers and exact package source/type snapshots, with no runtime data or secrets. artifact_import accepts that archive and a fresh new_name; hosted imports start private with new storage. Filesystem imports preserve backend source for deployment but do not execute it. +artifact_write creates or replaces the full source and then validates it. artifact_edit applies exact replacements and then validates; use artifact_read's source_hash as expected_hash to guard edits. A failed typecheck can leave source applied: inspect applied/ok and diagnostics, fix the source, and validate again. Do not treat an error as a rollback. Successful writes/edits show an inline preview in MCP Apps hosts. Use preview:false on write/edit/restore/import/remix for intermediate changes; validation and revision capture still run without delivering another view. artifact_typecheck and artifact_compile check existing artifacts; artifact_open shows one. artifact_history lists revisions; artifact_version reads archived source; artifact_restore restores source as a new revision while retaining current state. Hosted revisions include both client and server source. artifact_export returns a complete version 1 project archive including helpers and exact package source/type snapshots, with no runtime data or secrets. artifact_import accepts that archive and a fresh new_name; hosted imports start private with new storage. Filesystem imports preserve backend source for deployment but do not execute it. `; } diff --git a/src/mcp/host-contract.ts b/src/mcp/host-contract.ts new file mode 100644 index 0000000..96466ce --- /dev/null +++ b/src/mcp/host-contract.ts @@ -0,0 +1,34 @@ +/** Host UI support is negotiated independently from server authentication. */ +export const ARTIFACTS_DISPLAY_MODES = ["inline", "fullscreen"] as const; + +const sharedTools = new Set([ + "artifact_open", "artifact_list", "artifact_read", "artifact_history", "artifact_version", "artifact_export", + "artifact_request", "artifact_files", "artifact_plugin_call", "plugins_list", "plugin_guide", +]); + +/** UI visibility does not grant access; the server still authorizes every call. */ +export function applyToolVisibility }>(tools: readonly T[]): void { + for (const tool of tools) { + const ui = tool._meta?.ui as Record | undefined; + if (ui?.visibility) continue; + tool._meta = { ...tool._meta, ui: { ...ui, visibility: sharedTools.has(tool.name) ? ["model", "app"] : ["model"] } }; + } +} + +export function clientSupportsApps(capabilities: unknown): boolean { + const value = capabilities as { extensions?: Record } | undefined; + const mimeTypes = value?.extensions?.["io.modelcontextprotocol/ui"]?.mimeTypes; + return Array.isArray(mimeTypes) && mimeTypes.includes("text/html;profile=mcp-app"); +} + +/** Text clients keep every model tool and its ordinary annotations/schema. */ +export function toolsForClient }>(tools: readonly T[], apps: boolean): readonly T[] { + if (apps) return tools; + return tools.filter(tool => { + const visibility = (tool._meta?.ui as { visibility?: string[] } | undefined)?.visibility; + return !visibility || visibility.includes("model"); + }).map(tool => { + const { _meta, ...rest } = tool; + return rest as T; + }); +} diff --git a/src/mcp/local-tools.ts b/src/mcp/local-tools.ts index b870c59..7450e05 100644 --- a/src/mcp/local-tools.ts +++ b/src/mcp/local-tools.ts @@ -9,6 +9,12 @@ const PROTOCOL_VERSION = "2025-06-18"; export { MCP_TOOLS } from "./tools"; import { MCP_TOOLS } from "./tools"; import { parseProjectArchive } from "../project-archive"; +import { ARTIFACTS_WORKSPACE_TOOLS, workspaceResult } from "./workspace-contract"; +import { localWorkspace, localMentions, localResource } from "./local-workspace"; +import { ARTIFACTS_FILE_TOOL, artifactFileResult } from "./file-contract"; +import { clientSupportsApps, toolsForClient } from "./host-contract"; + +const appClients = new WeakMap(); export async function handleMcpRequest( request: JsonRpcRequest, @@ -20,6 +26,8 @@ export async function handleMcpRequest( try { if (request.method === "initialize") { + const params = request.params as { capabilities?: unknown } | undefined; + appClients.set(service, clientSupportsApps(params?.capabilities)); return ok(request.id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, resources: {} }, @@ -30,18 +38,22 @@ export async function handleMcpRequest( return ok(request.id, {}); } if (request.method === "tools/list") { - return ok(request.id, { tools: MCP_TOOLS }); + return ok(request.id, { tools: toolsForClient([...MCP_TOOLS, ...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL], appClients.get(service) ?? true) }); } if (request.method === "resources/list") return ok(request.id, { resources: [ARTIFACTS_RESOURCE] }); if (request.method === "resources/templates/list") return ok(request.id, { resourceTemplates: [] }); if (request.method === "resources/read") { const params = request.params as { uri?: string } | undefined; + if (params?.uri?.startsWith("artifact:")) return ok(request.id, { contents: [{ uri: params.uri, mimeType: "application/json", text: JSON.stringify(localResource(service, params.uri)) }] }); if (params?.uri !== ARTIFACTS_APP_URI) return error(request.id, -32002, "unknown resource"); return ok(request.id, { contents: [{ ...ARTIFACTS_RESOURCE, text: await artifactAppHtml() }] }); } if (request.method === "tools/call") { const params = (request.params ?? {}) as { name?: string; arguments?: Record }; + const apps = appClients.get(service) ?? true; + if (!apps && [...ARTIFACTS_WORKSPACE_TOOLS, ARTIFACTS_FILE_TOOL].some(tool => tool.name === params.name)) throw new Error("MCP Apps support is required for this tool"); const result = await callTool(service, params.name ?? "", params.arguments ?? {}); + if (!apps) { const { _meta, ...textResult } = result; return ok(request.id, textResult); } return ok(request.id, result); } return error(request.id, -32601, `unknown method: ${request.method}`); @@ -55,12 +67,27 @@ async function callTool( name: string, args: Record, ): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean; structuredContent?: Record; _meta?: Record }> { + if (name === "artifacts_file") return artifactFileResult(args); + if (name === "artifacts_library" || name === "artifacts_working" || name === "artifacts_search") { + const view = name === "artifacts_working" || args.view === "working" ? "working" : "library"; + return workspaceResult(localWorkspace(service, view, args.query as string | undefined, args.offset as number | undefined)); + } + if (name === "artifacts_mentions") { + const items = localMentions(service, args.query as string); + return { ...text(JSON.stringify({ items })), structuredContent: { items } }; + } + 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 })); + } + 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"); if (name === "artifact_guide") return artifactGuideResult(); if (name === "artifact_export") return text(JSON.stringify(service.exportProject({ name: args.name as string | undefined, version_id: args.version_id as string | undefined }))); if (name === "artifact_import") { const archive = parseProjectArchive(args.archive, "artifact"); const result = service.importProject(args.new_name as string, archive); - return withPreview(service, result, { name: result.name }); + return withPreview(service, result, { name: result.name }, deliver); } if (name === "artifact_read" || name === "artifact_edit") { if (typeof args.name !== "string") throw new Error("artifact name must be a string"); @@ -68,17 +95,17 @@ async function callTool( return text(JSON.stringify(service.readRange(args.name, { file: args.file, start_line: args.start_line, end_line: args.end_line } as ReadOptions))); } const result = service.edit(args.name, args.edits as ArtifactEdit[], args.expected_hash as string | undefined, args.file as string | undefined); - return withPreview(service, result, { name: args.name }); + return withPreview(service, result, { name: args.name }, deliver); } if (name === "artifact_history") return text(JSON.stringify({ versions: service.history(args.name === undefined ? undefined : String(args.name)) }, null, 2)); if (name === "artifact_version") return text(JSON.stringify(service.version(String(args.version_id)), null, 2)); if (name === "artifact_remix") { const result = service.remix(args as Parameters[0]); - return withPreview(service, result, { name: result.name }); + return withPreview(service, result, { name: result.name }, deliver); } if (name === "artifact_restore") { const result = service.restore(String(args.version_id)); - return withPreview(service, result, { name: artifactIdFromFile(result.path) }); + return withPreview(service, result, { name: artifactIdFromFile(result.path) }, deliver); } if (name === "artifact_list") { return text(JSON.stringify({ artifacts: service.list() }, null, 2)); @@ -91,7 +118,7 @@ async function callTool( opened = await service.open(written.path); } const payload = { ...written, opened }; - return withPreview(service, payload, { name: String(args.name) }); + return withPreview(service, payload, { name: String(args.name) }, deliver); } if (name === "artifact_typecheck") { const result = service.typecheck(String(args.name)); @@ -126,11 +153,11 @@ function previewResult(result: Awaited>) { return { ...text(`${result.check}\n\n${JSON.stringify(payload, null, 2)}`, !result.ok), structuredContent: payload, ...(_meta ? { _meta } : {}) }; } -async function withPreview(service: ArtifactService, result: { ok: boolean; check: string; [key: string]: unknown }, selection: { name: string }) { +async function withPreview(service: ArtifactService, result: { ok: boolean; check: string; [key: string]: unknown }, selection: { name: string }, deliver = true) { if (!result.ok) return { ...text(JSON.stringify(result, null, 2), true), structuredContent: result }; // Write/edit/restore have already applied. Preview failure must not imply rollback. try { - const preview = await artifactAppResult(service, selection); + const preview = await artifactAppResult(service, selection, deliver); const { _meta, ...details } = preview; const payload = { ...result, preview: details }; return { ...text(JSON.stringify(preview.ok ? result : payload, null, 2), !preview.ok), structuredContent: payload, ...(_meta ? { _meta } : {}) }; diff --git a/src/mcp/local-workspace.ts b/src/mcp/local-workspace.ts new file mode 100644 index 0000000..66b4d21 --- /dev/null +++ b/src/mcp/local-workspace.ts @@ -0,0 +1,44 @@ +import { resolve } from "node:path"; +import type { ArtifactService } from "../service"; +import { artifactResourceUri, type ArtifactWorkspaceItem, type ArtifactWorkspacePayload } from "./workspace-contract"; + +export function localWorkspace(service: ArtifactService, view: "library" | "working", query = "", offset = 0): ArtifactWorkspacePayload { + if (!Number.isSafeInteger(offset) || offset < 0) throw new Error("offset must be a nonnegative integer"); + if (typeof query !== "string" || query.length > 200) throw new Error("query must be at most 200 characters"); + const workspace = resolve(service.artifactsDir); + const items = new Map(); + for (const file of service.list()) items.set(file.id, { name: file.id, workspace, working: true, versions: [] }); + for (const version of service.history()) { + const item = items.get(version.name) ?? { name: version.name, workspace, working: false, versions: [] }; + item.versions.push({ id: version.version_id, revision: version.revision, createdAt: version.created_at }); + items.set(item.name, item); + } + const filtered = [...items.values()].filter(item => item.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())) + .sort((a, b) => a.name.localeCompare(b.name)); + return { view, workspace, items: filtered.slice(offset, offset + 100), nextOffset: filtered.length > offset + 100 ? offset + 100 : null }; +} + +export function localMentions(service: ArtifactService, query: string) { + return localWorkspace(service, "library", query).items.map(item => ({ + type: "resource_link" as const, uri: artifactResourceUri(item, item.working ? undefined : item.versions[0]?.id), + name: item.name, title: item.name, mimeType: "application/json", + description: "React artifact source and revision metadata in the connected workspace", + })); +} + +export function localResource(service: ArtifactService, uri: string) { + const url = new URL(uri); + if (url.protocol !== "artifact:" || url.hostname !== "project" || url.pathname || url.hash + || [...url.searchParams.keys()].some(key => !["workspace", "name", "version_id"].includes(key))) throw new Error("unknown artifact resource"); + const workspace = url.searchParams.get("workspace"); + const name = url.searchParams.get("name"); + const versionId = url.searchParams.get("version_id"); + if (workspace !== resolve(service.artifactsDir) || !name) throw new Error("artifact resource is outside this workspace"); + if (versionId) { + const saved = service.version(versionId); + if (saved.name !== name) throw new Error("artifact resource version does not match its name"); + return { name, workspace, version_id: saved.id, revision: saved.revision, source: saved.source, source_hash: saved.source_hash }; + } + const current = service.readRange(name, { end_line: Number.MAX_SAFE_INTEGER }); + return { name, workspace, source: current.source, source_hash: current.source_hash }; +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 5127340..6172ee5 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,5 +1,6 @@ import { ARTIFACTS_APP_META } from "./app-contract"; import { PROJECT_ARCHIVE_SCHEMA } from "../project-archive-contract"; +import { applyToolVisibility } from "./host-contract"; export const MCP_TOOLS = [ { @@ -124,3 +125,12 @@ export const MCP_TOOLS = [ }, }, ] as const; + +for (const tool of MCP_TOOLS) { + if (["artifact_write", "artifact_edit", "artifact_restore", "artifact_remix", "artifact_import"].includes(tool.name)) { + (tool.inputSchema as { properties: Record }).properties.preview = { + type: "boolean", default: true, description: "Set false for an intermediate edit: validate and save without replacing the chat preview.", + }; + } +} +applyToolVisibility(MCP_TOOLS); diff --git a/src/mcp/workspace-contract.ts b/src/mcp/workspace-contract.ts new file mode 100644 index 0000000..0e39aea --- /dev/null +++ b/src/mcp/workspace-contract.ts @@ -0,0 +1,64 @@ +import { ARTIFACTS_APP_URI } from "./app-contract"; + +export type ArtifactWorkspaceItem = { + name: string; + workspace: string; + working: boolean; + versions: Array<{ id: string; revision: number; createdAt: string }>; +}; +export type ArtifactWorkspacePayload = { + view: "library" | "working"; + workspace: string; + items: ArtifactWorkspaceItem[]; + nextOffset: number | null; +}; + +const ui = { resourceUri: ARTIFACTS_APP_URI, visibility: ["app"] }; +const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false }; +const emptySchema = { type: "object", properties: {}, additionalProperties: false }; + +/** Entrypoints never mutate the library and accept the host's empty arguments. */ +export const ARTIFACTS_WORKSPACE_TOOLS = [ + { + name: "artifacts_library", title: "Artifacts", description: "Browse accessible React artifacts and their saved versions.", + annotations: readOnly, + _meta: { ui, "openai/ui": { entrypoints: [{ type: "global" }] } }, + inputSchema: emptySchema, + }, + { + name: "artifacts_working", title: "Working artifacts", description: "Open the conversation's artifact working view.", + annotations: readOnly, + _meta: { ui, "openai/ui": { entrypoints: [{ type: "thread" }] } }, + inputSchema: emptySchema, + }, + { + name: "artifacts_search", description: "Browse accessible artifacts. Used by the library UI for filtering and pagination.", + annotations: readOnly, _meta: { ui: { visibility: ["app"] } }, + inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 200 }, offset: { type: "integer", minimum: 0 }, view: { type: "string", enum: ["library", "working"] } }, additionalProperties: false }, + }, + { + 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 }, + }, + { + name: "artifacts_mentions", description: "Search accessible artifacts for composer mentions.", + annotations: readOnly, + _meta: { ui: { visibility: ["app"] }, "openai/extensions": { "mentions/search": {} } }, + inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 200 } }, required: ["query"], additionalProperties: false }, + }, +]; + +export function artifactResourceUri(item: Pick, versionId?: string): string { + const query = new URLSearchParams({ workspace: item.workspace, name: item.name }); + if (versionId) query.set("version_id", versionId); + return `artifact://project?${query}`; +} + +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 }, + _meta: { workspace: payload }, + }; +} diff --git a/src/mcp/workspace.test.ts b/src/mcp/workspace.test.ts new file mode 100644 index 0000000..865604d --- /dev/null +++ b/src/mcp/workspace.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { ArtifactService } from "../service"; +import { tempDir, VALID_ARTIFACT } from "../test/fixtures"; +import { join } from "node:path"; +import { handleMcpRequest } from "./local-tools"; +import { artifactResourceUri } from "./workspace-contract"; +import { localResource, localWorkspace } from "./local-workspace"; + +function fixture() { + const dir = tempDir(); + const service = new ArtifactService({ artifactsDir: dir, env: { ARTIFACTS_HISTORY_DB: join(dir, "history.sqlite") } }); + const request = (method: string, params: unknown) => handleMcpRequest({ jsonrpc: "2.0", id: 1, method, params }, service); + return { dir, service, request }; +} + +test("library entrypoints accept empty args and mention resources stay in the connected workspace", async () => { + const { dir, service, request } = fixture(); + service.write("revenue", VALID_ARTIFACT); + const response = await request("tools/call", { name: "artifacts_library", arguments: {} }); + expect(response?.result).toMatchObject({ _meta: { workspace: { view: "library", items: [{ name: "revenue", workspace: dir, working: true }] } } }); + const mentions = (await request("tools/call", { name: "artifacts_mentions", arguments: { query: "reven" } }))?.result as { structuredContent: { items: { uri: string }[] } }; + const uri = mentions.structuredContent.items[0]!.uri; + expect(localResource(service, uri)).toMatchObject({ name: "revenue", source: VALID_ARTIFACT }); + expect(() => localResource(service, artifactResourceUri({ workspace: tempDir(), name: "revenue" }))).toThrow("outside this workspace"); + expect(localWorkspace(service, "working", "missing").items).toEqual([]); +}); + +test("preview suppression saves compiled source history without recording a delivery", async () => { + const { service, request } = fixture(); + const response = await request("tools/call", { name: "artifact_write", arguments: { name: "quiet", contents: VALID_ARTIFACT, preview: false } }); + expect(response?.result).toMatchObject({ isError: false }); + expect(response?.result).not.toHaveProperty("_meta"); + const history = service.history("quiet"); + expect(history).toHaveLength(1); + expect(history[0]?.serve_count).toBe(0); + const opened = await request("tools/call", { name: "artifacts_preview", arguments: { version_id: history[0]!.version_id } }); + expect(opened?.result).toHaveProperty("_meta.artifact.js"); + expect(service.history("quiet")[0]?.serve_count).toBe(1); +}); + +test("text clients keep model workflows without UI entrypoints or executable preview payloads", async () => { + const { request } = fixture(); + await request("initialize", { capabilities: {} }); + const response = (await request("tools/list", {}))?.result as { tools: { name: string; _meta?: unknown }[] }; + expect(response.tools.some(tool => tool.name === "artifact_write")).toBe(true); + expect(response.tools.some(tool => tool.name === "artifacts_library")).toBe(false); + expect(response.tools.every(tool => tool._meta === undefined)).toBe(true); + const written = await request("tools/call", { name: "artifact_write", arguments: { name: "plain", contents: VALID_ARTIFACT } }); + expect(written?.result).toMatchObject({ isError: false, structuredContent: { ok: true } }); + expect(written?.result).not.toHaveProperty("_meta"); +}); + +test("file entrypoint validates an opaque envelope without resolving it on the server", async () => { + const { request } = fixture(); + const file = { name: "revenue.artifact.tsx", resourceUri: "host-owned:opaque-token" }; + expect((await request("tools/call", { name: "artifacts_file", arguments: { file } }))?.result).toMatchObject({ structuredContent: { file } }); + expect((await request("tools/call", { name: "artifacts_file", arguments: { file: { ...file, name: "ordinary.tsx" } } }))?.error).toBeDefined(); + expect((await request("tools/call", { name: "artifacts_file", arguments: { file: { ...file, resourceUri: " " } } }))?.error).toBeDefined(); +}); diff --git a/src/runtime/mcp-app.ts b/src/runtime/mcp-app.ts index e941763..b6994ec 100644 --- a/src/runtime/mcp-app.ts +++ b/src/runtime/mcp-app.ts @@ -3,10 +3,17 @@ import type { ArtifactAppPayload } from "../mcp/app"; import type { ArtifactAction, HostBridge } from "../sdk/hooks"; import type { ArtifactHttpRequest, ArtifactHttpResponse } from "../sdk/server"; import { artifactFileTransferUrl } from "../sdk/files"; +import { createMcpHost } from "./mcp-host"; +import { mountArtifactFileEditor } from "./mcp-file-editor"; +import { fetchArtifactPreview, mountWorkspace, parseArtifactDeepLink, type ArtifactSelection } from "./mcp-workspace"; +import type { ArtifactWorkspacePayload } from "../mcp/workspace-contract"; +import type { ArtifactModelContext } from "../sdk/hooks"; +import { ARTIFACTS_DISPLAY_MODES } from "../mcp/host-contract"; // Measure intrinsic content rather than the iframe's document height, so views // can grow and shrink even when the host clamps or ignores a resize request. -const app = new App({ name: "Artifacts", version: "0.1.0" }, {}, { autoResize: false }); +const app = new App({ name: "Artifacts", version: "0.1.0" }, { availableDisplayModes: [...ARTIFACTS_DISPLAY_MODES] }, { autoResize: false }); +const host = createMcpHost(app); const hostWindow = window as Window & { __artifacts?: HostBridge; __artifactsUnmount?: () => void; __herdrCanvas?: HostBridge; __herdrCanvasUnmount?: () => void }; const status = document.getElementById("status")!; const shell = document.getElementById("artifact-shell")!; @@ -15,6 +22,27 @@ const root = document.getElementById("root")!; const toolbar = document.getElementById("artifact-toolbar")!; const displayButton = document.getElementById("display-mode") as HTMLButtonElement; let script: HTMLScriptElement | undefined; +let activeArtifact: ArtifactAppPayload | undefined; +let disposeView: (() => void) | undefined; +let viewDisposal: Promise = Promise.resolve(); +let fileEditor: ReturnType | undefined; +let activeDeepLink: string | undefined; +let pendingContextId: string | undefined; +let opening = 0; +let contextEpoch = 0; +let lastWorkspace: ArtifactWorkspacePayload | undefined; +const controls = document.createElement("nav"); +controls.setAttribute("aria-label", "Working artifact controls"); +controls.hidden = true; +controls.className = "artifact-controls"; +const libraryButton = document.createElement("button"); +libraryButton.textContent = "Back to library"; +const refreshButton = document.createElement("button"); +refreshButton.textContent = "Refresh artifact"; +const attachButton = document.createElement("button"); +attachButton.textContent = "Attach artifact context"; +controls.append(libraryButton, refreshButton, attachButton); +root.before(controls); let context: McpUiHostContext = {}; let connected = false; let hasArtifact = false; @@ -31,7 +59,7 @@ function reportSize() { // Fullscreen/PiP dimensions belong to the host, not the artifact content. if (!connected || (context.displayMode && context.displayMode !== "inline")) return; const contentHeight = Math.max(root.getBoundingClientRect().height, root.scrollHeight) - + status.getBoundingClientRect().height + + status.getBoundingClientRect().height + (controls.hidden ? 0 : controls.getBoundingClientRect().height) + (viewport.offsetHeight - viewport.clientHeight); const height = Math.max(1, Math.ceil(fixedHeight ?? Math.min(inlineLimit, contentHeight))); if (height === lastHeight) return; @@ -42,7 +70,7 @@ function reportSize() { }); } const resizeObserver = new ResizeObserver(reportSize); -for (const element of [shell, viewport, root, status, toolbar]) resizeObserver.observe(element); +for (const element of [shell, viewport, root, status, toolbar, controls]) resizeObserver.observe(element); function updateDisplay() { const mode = context.displayMode ?? "inline"; @@ -67,6 +95,27 @@ function updateDisplay() { function applyContext(update: McpUiHostContext) { const previousMode = context.displayMode; context = { ...context, ...update }; + host.applyContext(context); + const attachment = host.extensions.modelContext?.getCurrent(); + if (hostWindow.__artifacts && attachment !== undefined) { + const bridge = hostWindow.__artifacts; + const identity = attachment?.structuredContent?.artifact as { version_id?: string } | undefined; + bridge.contextAttached = attachment !== null && identity?.version_id === activeArtifact?.versionId; + if (!attachment) { contextEpoch++; bridge.modelContext = null; pendingContextId = undefined; } + else if (!pendingContextId || attachment.updateId === pendingContextId) { + bridge.modelContext = (bridge.contextAttached ? attachment.structuredContent?.view ?? null : null) as ArtifactModelContext | null; + } + } + const deepLink = host.extensions.deepLink.getCurrent()?.url; + if (deepLink && deepLink !== activeDeepLink) { + activeDeepLink = deepLink; + const selection = parseArtifactDeepLink(deepLink); + if (selection) void openSelection(selection).catch(error => { status.textContent = String(error); }); + } + if (hostWindow.__artifacts) { + hostWindow.__artifacts.environment = { locale: context.locale, timeZone: context.timeZone }; + window.dispatchEvent(new Event("artifact-host-context-change")); + } if (previousMode !== context.displayMode) lastHeight = undefined; theme(context.theme); updateDisplay(); @@ -101,15 +150,7 @@ function theme(kind?: string) { async function action(value: ArtifactAction) { try { - let result: { isError?: boolean }; - if (value.type === "openUrl") { - if (!/^https?:\/\//i.test(value.url)) throw new Error("Only HTTP and HTTPS links can be opened from this artifact."); - result = await app.openLink({ url: value.url }); - } else if (value.type === "promptAgent") { - result = await app.sendMessage({ role: "user", content: [{ type: "text", text: value.prompt }] }); - } else { - throw new Error(`Opening local files is unavailable in this chat: ${value.path}`); - } + const result: { isError?: boolean } = await host.action(value); if (result.isError) throw new Error("The chat host declined this artifact action."); status.textContent = ""; } catch (error) { status.textContent = error instanceof Error ? error.message : String(error); } @@ -118,7 +159,7 @@ async function action(value: ArtifactAction) { async function serverRequest(artifact: ArtifactAppPayload, request: ArtifactHttpRequest): Promise { const result = await app.callServerTool({ name: "artifact_request", - arguments: { version_id: artifact.versionId, request }, + arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), request }, }); if (result.isError) { const message = result.content?.filter(item => item.type === "text").map(item => item.text).join("\n"); @@ -130,6 +171,15 @@ async function serverRequest(artifact: ArtifactAppPayload, request: ArtifactHttp } function clear() { + disposeView?.(); + disposeView = undefined; + if (fileEditor) { + const disposal = fileEditor.dispose(); + viewDisposal = Promise.all([viewDisposal, disposal]).then(() => {}); + fileEditor = undefined; + } + activeArtifact = undefined; + controls.hidden = true; for (const unmount of new Set([hostWindow.__artifactsUnmount, hostWindow.__herdrCanvasUnmount])) unmount?.(); delete hostWindow.__artifactsUnmount; delete hostWindow.__herdrCanvasUnmount; @@ -142,22 +192,50 @@ function clear() { updateDisplay(); } -app.ontoolresult = result => { +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."); + const request = ++opening; + const artifact = await fetchArtifactPreview(app, selection); + if (request !== opening) return; + renderArtifact(artifact, selection.route, true); +} + +function renderArtifact(artifact: ArtifactAppPayload, route?: string, preserve = false) { + const previous = hostWindow.__artifacts; + const compatible = preserve && activeArtifact?.name === artifact.name && activeArtifact?.workspace === artifact.workspace; + const state = compatible ? { ...artifact.state, ...previous?.state } : artifact.state; + const restored = host.extensions.modelContext?.getCurrent(); + const restoredIdentity = restored?.structuredContent?.artifact as { version_id?: string } | undefined; + const restore = restoredIdentity?.version_id === artifact.versionId; + const modelContext = compatible ? previous?.modelContext : restore ? (restored?.structuredContent?.view ?? null) as ArtifactModelContext | null : null; + const contextAttached = compatible ? previous?.contextAttached && activeArtifact?.versionId === artifact.versionId : restore; + const previousRoute = compatible ? previous?.route?.path : undefined; clear(); - 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; - if (!artifact || typeof artifact.js !== "string") { - status.textContent = "No artifact preview was returned."; - return; - } - const bridge: HostBridge & { canvasId: string } = { canvasId: artifact.name, artifactId: artifact.name, state: artifact.state, onAction: value => { void action(value); } }; - if (artifact.server === true) bridge.onRequest = request => serverRequest(artifact, request); - if (artifact.files === true) { + activeArtifact = artifact; + const capabilities = host.capabilities(); + const bridge: HostBridge & { canvasId: string } = { + canvasId: artifact.name, artifactId: artifact.name, state, + route: { path: route ?? previousRoute ?? "/", basePath: "", external: true, transport: "mcp" }, + modelContext, contextAttached, + actions: capabilities.actions, environment: { locale: context.locale, timeZone: context.timeZone }, + onAction: value => { void action(value); }, + }; + if (capabilities.modelContext) bridge.onModelContext = async view => { + const epoch = contextEpoch; + const snapshot = view === null ? undefined : { artifact: { name: artifact.name, workspace: artifact.workspace, version_id: artifact.versionId, revision: artifact.revision }, view }; + const result = await host.updateContext(snapshot ? { structuredContent: snapshot, content: [{ type: "text", text: JSON.stringify(snapshot) }] } : { content: [], structuredContent: {} }); + // A late acknowledgement cannot attach context to a replacement view. + if (hostWindow.__artifacts !== bridge || epoch !== contextEpoch) return; + pendingContextId = result?.updateId; + bridge.modelContext = view; + bridge.contextAttached = view !== null; + window.dispatchEvent(new Event("artifact-host-context-change")); + }; + if (artifact.server === true && capabilities.serverTools) bridge.onRequest = request => serverRequest(artifact, request); + if (artifact.files === true && capabilities.serverTools) { bridge.onFileRequest = async request => { - const result = await app.callServerTool({ name: "artifact_files", arguments: { version_id: artifact.versionId, request } }); + const result = await app.callServerTool({ name: "artifact_files", arguments: { version_id: artifact.versionId, ...(artifact.workspace ? { workspace: artifact.workspace } : {}), request } }); if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Artifact file request failed"); if (!result.structuredContent || !Object.hasOwn(result.structuredContent, "result")) throw new Error("Artifact file result was missing"); return result.structuredContent.result; @@ -167,7 +245,7 @@ app.ontoolresult = result => { if (result.isError) throw new Error("The chat host declined the file download."); }; } - if (artifact.plugins === true) bridge.onPluginCall = async request => { + if (artifact.plugins === true && capabilities.serverTools) bridge.onPluginCall = async request => { const result = await app.callServerTool({ name: "artifact_plugin_call", arguments: { ...request } }); if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Plugin call failed"); const structured = result.structuredContent; @@ -176,6 +254,9 @@ app.ontoolresult = result => { }; hostWindow.__artifacts = hostWindow.__herdrCanvas = bridge; hasArtifact = true; + controls.hidden = !preserve; + libraryButton.hidden = !lastWorkspace; + attachButton.hidden = !capabilities.modelContext; theme(context.theme); updateDisplay(); status.textContent = ""; @@ -185,8 +266,75 @@ app.ontoolresult = result => { script.type = "module"; script.textContent = artifact.js; document.body.append(script); +} +app.ontoolresult = result => { + if (fileEditor?.hasUnsavedChanges()) { status.textContent = "Save or discard the file draft before replacing this view."; return; } + 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; + if (artifact?.js) { renderArtifact(artifact); return; } + const workspace = result._meta?.workspace as ArtifactWorkspacePayload | undefined; + if (workspace) { + clear(); + status.textContent = ""; + hasArtifact = true; + lastWorkspace = workspace; + disposeView = mountWorkspace(root, app, workspace, openSelection); + updateDisplay(); + // Each thread gets its own host instance. Reopen its explicit attachment, + // with fresh authorization, without inventing a server-side chat identity. + const attached = host.extensions.modelContext?.getCurrent(); + const selected = attached?.structuredContent?.artifact as { name?: unknown; workspace?: unknown; version_id?: unknown } | undefined; + if (workspace.view === "working" && typeof selected?.version_id === "string" && typeof selected.workspace === "string") { + const view = attached?.structuredContent?.view as ArtifactModelContext | undefined; + const route = typeof view?.route === "string" && view.route.startsWith("/") && !view.route.startsWith("//") ? view.route : "/"; + void openSelection({ workspace: selected.workspace, version_id: selected.version_id, route }).catch(error => { status.textContent = String(error); }); + } + return; + } + // Diagnostics-only mutations deliberately leave the working view mounted. +}; +app.ontoolinput = async ({ arguments: args }) => { + const file = args?.file as { resourceUri?: string; name?: string } | undefined; + if (!file || typeof file.resourceUri !== "string" || typeof file.name !== "string") return; + if (fileEditor?.hasUnsavedChanges()) { status.textContent = "Save or discard the file draft before opening another file."; return; } + const request = ++opening; + clear(); + await viewDisposal; + if (request !== opening) return; + fileEditor = mountArtifactFileEditor(root, host.extensions, { file: { resourceUri: file.resourceUri, name: file.name } }); + hasArtifact = true; + status.textContent = ""; + updateDisplay(); +}; +window.addEventListener("artifact/navigate", event => { + const detail = (event as CustomEvent<{ path: string; action: string }>).detail; + if (hostWindow.__artifacts?.route) hostWindow.__artifacts.route.path = detail.path; +}); +libraryButton.addEventListener("click", () => { + if (!lastWorkspace) return; + clear(); + hasArtifact = true; + disposeView = mountWorkspace(root, app, lastWorkspace, openSelection); + updateDisplay(); +}); +refreshButton.addEventListener("click", () => { + if (activeArtifact) void openSelection({ workspace: activeArtifact.workspace, name: activeArtifact.name }).catch(error => { status.textContent = String(error); }); +}); +attachButton.addEventListener("click", () => { + const bridge = hostWindow.__artifacts; + if (bridge?.onModelContext) void bridge.onModelContext({ ...bridge.modelContext, route: bridge.route?.path ?? "/" }).catch(error => { status.textContent = String(error); }); +}); +app.ontoolcancelled = () => { ++opening; if (fileEditor?.hasUnsavedChanges()) { status.textContent = "Request cancelled. Your unsaved file draft is preserved."; return; } clear(); status.textContent = "Artifact request cancelled."; }; +app.onteardown = async () => { + ++opening; + resizeObserver.disconnect(); + if (sizeFrame) cancelAnimationFrame(sizeFrame); + clear(); + await viewDisposal; + return {}; }; -app.ontoolcancelled = () => { clear(); status.textContent = "Artifact request cancelled."; }; app.onhostcontextchanged = applyContext; window.addEventListener("error", event => { status.textContent = `Artifact error: ${event.message}`; }); window.addEventListener("unhandledrejection", event => { status.textContent = `Artifact error: ${String(event.reason)}`; }); diff --git a/src/runtime/mcp-file-editor.test.ts b/src/runtime/mcp-file-editor.test.ts new file mode 100644 index 0000000..5b16bb1 --- /dev/null +++ b/src/runtime/mcp-file-editor.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import type { OpenAIResources, OpenAIResourceReadResult, OpenAIResourceWriteResult, OpenAIResourceUpdatedHandler } from "@openai/mcp-extensions/app"; +import { createArtifactFileSession } from "./mcp-file-editor"; + +const uri = "host-owned:opaque-token"; +const input = { file: { name: "demo.artifact.tsx", resourceUri: uri } }; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function fixture() { + let handler: OpenAIResourceUpdatedHandler | undefined; + let content: OpenAIResourceReadResult = { contents: [{ uri, text: "initial", openaiMetadata: { writable: true, etag: "v1" } }] }; + let outcome: OpenAIResourceWriteResult = { outcome: "saved", etag: "v2" }; + const writes: Array<{ uri: string; content: unknown }> = []; + const reads: unknown[] = []; + const unsubscribes: string[] = []; + const resources: OpenAIResources = { + addUpdateHandler(value) { handler = value; return () => { handler = undefined; }; }, + async read(params) { reads.push(params); return content; }, + async subscribe() { return {}; }, + async unsubscribe(params) { unsubscribes.push(params.uri); return {}; }, + async write(uri, content) { writes.push({ uri, content }); return outcome; }, + }; + return { resources, writes, reads, unsubscribes, setContent(value: OpenAIResourceReadResult) { content = value; }, setOutcome(value: OpenAIResourceWriteResult) { outcome = value; }, + update: () => handler?.({ method: "notifications/resources/updated", params: { uri } }), hasHandler: () => !!handler }; +} + +describe("owned artifact file editor", () => { + test("missing extension and general TSX stay unavailable", async () => { + const unavailable = createArtifactFileSession(undefined, input); + await unavailable.ready; + expect(unavailable.getSnapshot().message).toContain("does not support"); + const host = fixture(); + const unsupported = createArtifactFileSession(host.resources, { file: { ...input.file, name: "ordinary.tsx" } }); + await unsupported.ready; + expect(unsupported.getSnapshot().loaded).toBe(false); + expect(host.reads).toHaveLength(0); + expect(host.hasHandler()).toBe(false); + }); + + test.each([{ writable: false, etag: "v1" }, { writable: true }, { writable: true, etag: " " }])("requires writable access and nonempty etag: %j", async metadata => { + const host = fixture(); + host.setContent({ contents: [{ uri, text: "initial", openaiMetadata: metadata }] }); + const session = createArtifactFileSession(host.resources, input); + await session.ready; + session.edit("changed"); + await session.save(); + expect(session.getSnapshot().draft).toBe("initial"); + expect(session.getSnapshot().writable).toBe(false); + expect(host.writes).toHaveLength(0); + await session.dispose(); + }); + + test("requests text and decodes UTF-8 blobs strictly", async () => { + const host = fixture(); + host.setContent({ contents: [{ uri, blob: btoa("hello"), openaiMetadata: { writable: true, etag: "v1" } }] }); + const session = createArtifactFileSession(host.resources, input); + await session.ready; + expect(host.reads).toEqual([{ uri, representation: "text" }]); + expect(session.getSnapshot().draft).toBe("hello"); + host.setContent({ contents: [{ uri, blob: btoa(String.fromCharCode(0xff)) }] }); + await session.reload(); + expect(session.getSnapshot().message).toContain("Unable to read"); + expect(session.getSnapshot().draft).toBe("hello"); + await session.dispose(); + }); + + test("save uses opaque URI and matching etag, and advances baseline", async () => { + const host = fixture(); + const session = createArtifactFileSession(host.resources, input); + await session.ready; + session.edit("draft"); + await session.save(); + expect(host.writes).toEqual([{ uri, content: { text: "draft", ifMatch: "v1" } }]); + expect(session.hasUnsavedChanges()).toBe(false); + session.edit("next"); + await session.save(); + expect(host.writes[1]).toEqual({ uri, content: { text: "next", ifMatch: "v2" } }); + await session.dispose(); + }); + + test("conflict preserves draft and old version until explicit reload", async () => { + const host = fixture(); + host.setOutcome({ outcome: "conflict", etag: "other-version" }); + const session = createArtifactFileSession(host.resources, input); + await session.ready; + session.edit("mine"); + await session.save(); + expect(session.getSnapshot()).toMatchObject({ draft: "mine", baseline: "initial", etag: "v1", conflict: true }); + await session.save(); + expect(host.writes).toHaveLength(1); + host.setContent({ contents: [{ uri, text: "theirs", openaiMetadata: { writable: true, etag: "v3" } }] }); + await session.reload(); + expect(session.getSnapshot()).toMatchObject({ draft: "theirs", baseline: "theirs", etag: "v3", conflict: false }); + await session.dispose(); + }); + + test("too-large preserves draft and allows a smaller retry", async () => { + const host = fixture(); + host.setOutcome({ outcome: "too-large", maxBytes: 10 }); + const session = createArtifactFileSession(host.resources, input); + await session.ready; + session.edit("a large draft"); + await session.save(); + expect(session.getSnapshot()).toMatchObject({ draft: "a large draft", baseline: "initial", etag: "v1" }); + expect(session.getSnapshot().message).toContain("10 bytes"); + session.edit("small"); + host.setOutcome({ outcome: "saved", etag: "v2" }); + await session.save(); + expect(session.hasUnsavedChanges()).toBe(false); + await session.dispose(); + }); + + test("clean updates refresh, dirty updates preserve draft and announce conflict", async () => { + const host = fixture(); + const session = createArtifactFileSession(host.resources, input); + await session.ready; + host.setContent({ contents: [{ uri, text: "new", openaiMetadata: { writable: true, etag: "v2" } }] }); + await host.update(); + expect(session.getSnapshot().draft).toBe("new"); + session.edit("mine"); + await host.update(); + expect(session.getSnapshot()).toMatchObject({ draft: "mine", baseline: "new", conflict: true }); + expect(session.getSnapshot().message).toContain("draft is preserved"); + await session.dispose(); + }); + + test("typing during a read or save remains unsaved", async () => { + const host = fixture(); + const session = createArtifactFileSession(host.resources, input); + await session.ready; + const read = deferred(); + host.resources.read = () => read.promise; + const updating = host.update(); + session.edit("typed while reading"); + read.resolve({ contents: [{ uri, text: "external" }] }); + await updating; + expect(session.getSnapshot()).toMatchObject({ draft: "typed while reading", conflict: true }); + await session.dispose(); + const next = fixture(); + const savingSession = createArtifactFileSession(next.resources, input); + await savingSession.ready; + const write = deferred(); + next.resources.write = () => write.promise; + savingSession.edit("saved draft"); + const saving = savingSession.save(); + savingSession.edit("newer draft"); + write.resolve({ outcome: "saved", etag: "v2" }); + await saving; + expect(savingSession.getSnapshot()).toMatchObject({ draft: "newer draft", baseline: "saved draft", etag: "v2" }); + expect(savingSession.hasUnsavedChanges()).toBe(true); + await savingSession.dispose(); + }); + + test("dispose ignores stale reads and cleans a pending subscription exactly once", async () => { + const host = fixture(); + const read = deferred(); + const subscribe = deferred>(); + host.resources.read = () => read.promise; + host.resources.subscribe = () => subscribe.promise; + const session = createArtifactFileSession(host.resources, input); + const snapshot = session.getSnapshot(); + const disposing = session.dispose(); + expect(host.hasHandler()).toBe(false); + read.resolve({ contents: [{ uri, text: "stale" }] }); + subscribe.resolve({}); + await Promise.all([session.ready, disposing]); + await session.dispose(); + expect(session.getSnapshot()).toBe(snapshot); + expect(host.unsubscribes).toEqual([uri]); + }); +}); diff --git a/src/runtime/mcp-file-editor.tsx b/src/runtime/mcp-file-editor.tsx new file mode 100644 index 0000000..a400cb6 --- /dev/null +++ b/src/runtime/mcp-file-editor.tsx @@ -0,0 +1,152 @@ +import { useSyncExternalStore } from "react"; +import { createRoot } from "react-dom/client"; +import type { OpenAIExtensions, OpenAIFileEntrypointInput, OpenAIResources } from "@openai/mcp-extensions/app"; +import { SourceEditor } from "../gallery/source-editor"; + +export type ArtifactFileState = { + draft: string; baseline: string; loaded: boolean; writable: boolean; etag?: string; + busy: boolean; conflict: boolean; message: string; subscriptionWarning?: string; +}; + +/** Host URIs are opaque: only the host resources API may resolve or write them. */ +export function createArtifactFileSession(resources: OpenAIResources | undefined, input: OpenAIFileEntrypointInput) { + const uri = input.file.resourceUri; + const supported = input.file.name.endsWith(".artifact.tsx"); + let state: ArtifactFileState = { draft: "", baseline: "", loaded: false, writable: false, busy: false, conflict: false, message: "Loading file…" }; + let disposed = false; + let readVersion = 0; + let saving = false; + let refreshAfterSave = false; + let subscribed = false; + const listeners = new Set<() => void>(); + const dirty = () => state.draft !== state.baseline; + const publish = (patch: Partial) => { + if (disposed) return; + state = { ...state, ...patch }; + for (const listener of listeners) listener(); + }; + const externalConflict = () => publish({ conflict: true, message: "The file changed in the host. Your draft is preserved. Reload to discard your draft and read the latest file." }); + const read = async (discardDraft = false) => { + if (!resources || !supported || disposed) return; + if (!discardDraft && dirty()) { externalConflict(); return; } + const version = ++readVersion; + const originalDraft = state.draft; + publish({ busy: true }); + try { + const result = await resources.read({ uri, representation: "text" }); + if (disposed || version !== readVersion) return; + // A user may type while an automatic refresh is in flight. + if (state.draft !== originalDraft || (!discardDraft && dirty())) { publish({ busy: false }); externalConflict(); return; } + const content = result.contents.find(item => item.uri === uri); + if (!content) throw new Error("The host did not return the requested file."); + let text: string; + if ("text" in content && typeof content.text === "string") text = content.text; + else if ("blob" in content && typeof content.blob === "string") { + const bytes = Uint8Array.from(atob(content.blob), character => character.charCodeAt(0)); + text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } else throw new Error("The host returned no readable file content."); + const metadata = content.openaiMetadata; + const etag = metadata?.etag?.trim() ? metadata.etag : undefined; + const writable = metadata?.writable === true && !!etag; + publish({ draft: text, baseline: text, loaded: true, etag, writable, busy: false, conflict: false, + message: writable ? "Ready. Changes are saved only when you choose Save." : "Read only. The host must grant write access and provide a version identifier to enable saving." }); + } catch (error) { + if (version === readVersion) publish({ busy: false, message: `Unable to read file: ${error instanceof Error ? error.message : String(error)}` }); + } + }; + let removeHandler: (() => void) | undefined; + let subscription: Promise = Promise.resolve(); + if (!supported) { + publish({ message: "This editor supports only .artifact.tsx files." }); + } else if (!resources) { + publish({ message: "File editing is unavailable because this host does not support managed resources." }); + } else { + removeHandler = resources.addUpdateHandler(notification => { + if (disposed || notification.params.uri !== uri) return; + if (saving) { refreshAfterSave = true; return; } + return read(); + }); + subscription = resources.subscribe({ uri }).then(() => { subscribed = true; }).catch(() => { + publish({ subscriptionWarning: "Live file updates are unavailable. Reload to check for changes; saves still check the file version." }); + }); + } + const ready = resources && supported ? read() : Promise.resolve(); + return { + ready, + canReload: !!resources && supported, + getSnapshot: () => state, + subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; }, + hasUnsavedChanges: dirty, + edit(draft: string) { if (state.loaded && state.writable && !disposed) publish({ draft }); }, + reload() { if (saving || disposed) return Promise.resolve(); return read(true); }, + async save() { + if (!resources || disposed || saving || state.busy || !state.writable || !state.etag || state.conflict || !dirty()) return; + const draft = state.draft; + const etag = state.etag; + saving = true; + publish({ busy: true, message: "Saving…" }); + try { + const result = await resources.write(uri, { text: draft, ifMatch: etag }); + if (disposed) return; + if (result.outcome === "saved") { + const nextEtag = result.etag.trim() ? result.etag : undefined; + publish({ baseline: draft, etag: nextEtag, writable: !!nextEtag, message: nextEtag ? "Saved." : "Saved. The host did not return a version identifier; further saves are disabled." }); + } else if (result.outcome === "conflict") externalConflict(); + else if ("maxBytes" in result) publish({ message: `The host rejected this file because it exceeds ${result.maxBytes} bytes. Your draft is preserved.` }); + } catch (error) { + publish({ message: `Unable to save. Your draft is preserved. ${error instanceof Error ? error.message : String(error)}` }); + } finally { + saving = false; + publish({ busy: false }); + if (refreshAfterSave && !disposed) { refreshAfterSave = false; await read(); } + } + }, + async dispose() { + if (disposed) return; + disposed = true; + ++readVersion; + listeners.clear(); + removeHandler?.(); + await subscription; + if (resources && subscribed) await resources.unsubscribe({ uri }).catch(() => {}); + }, + }; +} + +export function mountArtifactFileEditor(root: HTMLElement, extensions: OpenAIExtensions, input: OpenAIFileEntrypointInput): { dispose(): Promise; hasUnsavedChanges(): boolean } { + const session = createArtifactFileSession(extensions.resources, input); + const reactRoot = createRoot(root); + function Editor() { + const state = useSyncExternalStore(session.subscribe, session.getSnapshot); + const canSave = state.loaded && state.writable && !state.busy && !state.conflict && session.hasUnsavedChanges(); + return
{ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { event.preventDefault(); void session.save(); } }} + style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 300, color: "var(--text, inherit)" }}> + +
+ {input.file.name} + {session.hasUnsavedChanges() ? "Unsaved changes" : state.loaded ? "No unsaved changes" : ""} + + +
+

{state.message}

+ {state.subscriptionWarning &&

{state.subscriptionWarning}

} + {state.loaded &&
} +
; + } + reactRoot.render(); + let unmounted = false; + return { hasUnsavedChanges: session.hasUnsavedChanges, async dispose() { if (!unmounted) { unmounted = true; reactRoot.unmount(); } await session.dispose(); } }; +} + +const fileEditorStyles = ` +.mcp-file-editor { --text: var(--color-text-primary, #202124); --subtle: var(--color-text-secondary, #656b73); --line: var(--color-border-primary, #ddd); --panel: var(--color-background-primary, #fff); --raised: var(--color-background-secondary, #f5f5f5); --selected: var(--color-background-tertiary, #dce6ef); --syntax-keyword: var(--text); --syntax-string: var(--text); --syntax-number: var(--text); --syntax-type: var(--text); --syntax-function: var(--text); background: var(--panel); font: 14px/1.5 var(--font-sans, system-ui, sans-serif); } +.mcp-file-editor button { background: var(--raised); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 5px 10px; font: inherit; cursor: pointer; } +.mcp-file-editor button:disabled { opacity: .5; cursor: default; } +.mcp-file-editor button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; } +.mcp-file-editor .source-code-editor { display: flex; flex-direction: column; height: 100%; min-height: 180px; min-width: 0; } +.mcp-file-editor .source-code-surface { flex: 1; min-height: 0; min-width: 0; } +.mcp-file-editor .source-code-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 12px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; flex-shrink: 0; } +`; diff --git a/src/runtime/mcp-host.ts b/src/runtime/mcp-host.ts new file mode 100644 index 0000000..f71a80a --- /dev/null +++ b/src/runtime/mcp-host.ts @@ -0,0 +1,47 @@ +import { App, applyDocumentTheme, applyHostStyleVariables, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; +import { OpenAIExtensions } from "@openai/mcp-extensions/app"; +import type { ArtifactHostActions } from "../sdk/hooks"; + +/** OpenAI APIs stay here; artifact source consumes the portable SDK bridge. */ +export function createMcpHost(app: App) { + const extensions = new OpenAIExtensions(app); + return { + extensions, + capabilities() { + const host = app.getHostCapabilities(); + const actions: ArtifactHostActions = { + openUrl: host?.openLinks !== undefined, + promptAgent: host?.message?.text !== undefined, + openFile: extensions.files !== undefined, + }; + return { actions, serverTools: host?.serverTools !== undefined, modelContext: host?.updateModelContext?.text !== undefined }; + }, + applyContext(context: McpUiHostContext) { + if (context.theme) applyDocumentTheme(context.theme); + if (context.styles?.variables) applyHostStyleVariables(context.styles.variables); + if (context.locale) document.documentElement.lang = context.locale; + }, + async action(value: import("../sdk/hooks").ArtifactAction) { + const { actions } = this.capabilities(); + if (value.type === "openUrl") { + if (!actions.openUrl) throw new Error("Opening links is unavailable in this chat"); + const url = new URL(value.url); + if (!["http:", "https:"].includes(url.protocol)) throw new Error("Only HTTP and HTTPS links can be opened from this artifact."); + return app.openLink({ url: url.href }); + } + if (value.type === "promptAgent") { + if (!actions.promptAgent) throw new Error("Sending messages is unavailable in this chat"); + return app.sendMessage({ role: "user", content: [{ type: "text", text: value.prompt }] }); + } + if (!extensions.files) throw new Error(`Opening local files is unavailable in this chat: ${value.path}`); + await extensions.files.open(value.path); + return {}; + }, + async updateContext(params: Parameters[0]) { + if (!this.capabilities().modelContext) throw new Error("Model context is unavailable in this chat"); + if (extensions.modelContext) return extensions.modelContext.update(params); + await app.updateModelContext(params); + return undefined; + }, + }; +} diff --git a/src/runtime/mcp-workspace.test.ts b/src/runtime/mcp-workspace.test.ts new file mode 100644 index 0000000..342fa2f --- /dev/null +++ b/src/runtime/mcp-workspace.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { parseArtifactDeepLink } from "./mcp-workspace"; + +test("deep links keep authenticated selectors and internal routes and reject external targets", () => { + const query = new URLSearchParams({ workspace: "research", name: "dashboard", version_id: "saved-version", route: "/accounts/456?tab=details" }); + expect(parseArtifactDeepLink(`/artifact?${query}`)).toEqual({ workspace: "research", name: undefined, version_id: "saved-version", route: "/accounts/456?tab=details" }); + for (const target of ["https://external.example/artifact?name=dashboard", "/artifact", "/artifact?name=dashboard&route=//external.example", "http://[invalid"]) { + expect(parseArtifactDeepLink(target)).toBeNull(); + } +}); diff --git a/src/runtime/mcp-workspace.ts b/src/runtime/mcp-workspace.ts new file mode 100644 index 0000000..4be642a --- /dev/null +++ b/src/runtime/mcp-workspace.ts @@ -0,0 +1,96 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { ArtifactWorkspacePayload } from "../mcp/workspace-contract"; +import type { ArtifactAppPayload } from "../mcp/app-contract"; + +export type ArtifactSelection = { name?: string; workspace?: string; version_id?: string; route?: string }; + +/** Deep links contain authenticated selectors, never file-transfer credentials. */ +export function parseArtifactDeepLink(value: string): ArtifactSelection | null { + let url: URL; + try { url = new URL(value, "https://artifacts.invalid"); } catch { return null; } + if (url.origin !== "https://artifacts.invalid" || url.pathname !== "/artifact") return null; + const name = url.searchParams.get("name") ?? undefined; + const version_id = url.searchParams.get("version_id") ?? undefined; + const workspace = url.searchParams.get("workspace") ?? undefined; + const route = url.searchParams.get("route") ?? "/"; + if ((!name && !version_id) || !route.startsWith("/") || route.startsWith("//")) return null; + return { name: version_id ? undefined : name, workspace, version_id, route }; +} + +export async function fetchArtifactPreview(app: App, selection: ArtifactSelection): Promise { + const { route, ...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; + if (!artifact?.js) throw new Error("The server returned no artifact preview"); + return artifact; +} + +export function mountWorkspace(root: HTMLElement, app: App, initial: ArtifactWorkspacePayload, open: (selection: ArtifactSelection) => Promise) { + let disposed = false; + let generation = 0; + let query = ""; + const section = document.createElement("section"); + const title = document.createElement("h1"); + title.textContent = initial.view === "library" ? "Artifacts" : "Working artifacts"; + const label = document.createElement("label"); + label.textContent = "Search artifacts "; + const input = document.createElement("input"); + input.type = "search"; + input.maxLength = 200; + const form = document.createElement("form"); + const search = document.createElement("button"); + search.textContent = "Search"; + search.type = "button"; + label.append(input); + form.append(label, search); + const list = document.createElement("ul"); + const notice = document.createElement("p"); + notice.setAttribute("role", "status"); + const more = document.createElement("button"); + more.textContent = "Load more"; + let nextOffset = initial.nextOffset; + section.append(title, form, list, notice, more); + root.append(section); + function render(payload: ArtifactWorkspacePayload, append: boolean) { + if (!append) list.replaceChildren(); + for (const item of payload.items) { + const row = document.createElement("li"); + const button = document.createElement("button"); + button.textContent = `Open ${item.name}`; + const versions = document.createElement("select"); + versions.setAttribute("aria-label", `Revision of ${item.name}`); + if (item.working) versions.add(new Option("Working source", "")); + for (const version of item.versions) versions.add(new Option(`Revision ${version.revision}`, version.id)); + button.addEventListener("click", () => { + const version_id = versions.value || undefined; + void open({ workspace: item.workspace, name: version_id ? undefined : item.name, version_id }).catch(error => { notice.textContent = String(error); }); + }); + row.append(button, versions); + list.append(row); + } + nextOffset = payload.nextOffset; + more.hidden = nextOffset === null; + notice.textContent = list.children.length ? "" : "No artifacts found."; + } + async function load(append: boolean) { + const current = ++generation; + search.disabled = more.disabled = true; + try { + const result = await app.callServerTool({ name: "artifacts_search", arguments: { query, view: initial.view, offset: append ? nextOffset : 0 } }); + if (disposed || current !== generation) return; + if (result.isError) throw new Error(result.content?.filter(item => item.type === "text").map(item => item.text).join("\n") || "Search failed"); + const payload = result._meta?.workspace as ArtifactWorkspacePayload | undefined; + if (!payload) throw new Error("Workspace response was missing"); + render(payload, append); + } catch (error) { if (!disposed && current === generation) notice.textContent = String(error); } + finally { if (!disposed && current === generation) search.disabled = more.disabled = false; } + } + const searchNow = () => { query = input.value; void load(false); }; + search.addEventListener("click", searchNow); + input.addEventListener("keydown", event => { if (event.key === "Enter") { event.preventDefault(); searchNow(); } }); + form.addEventListener("submit", event => { event.preventDefault(); }); + more.addEventListener("click", () => { void load(true); }); + render(initial, false); + return () => { disposed = true; generation++; section.remove(); }; +} diff --git a/src/sdk/hooks.test.ts b/src/sdk/hooks.test.ts index a688e6b..450124f 100644 --- a/src/sdk/hooks.test.ts +++ b/src/sdk/hooks.test.ts @@ -8,9 +8,9 @@ test("capability discovery reports installed runtime bridges without executing t let calls = 0; try { delete host.__artifacts; - expect(getArtifactCapabilities()).toEqual({ server: false, files: false, plugins: false, hostActions: false, statePersistence: "session" }); + expect(getArtifactCapabilities()).toEqual({ server: false, files: false, plugins: false, hostActions: false, actions: { openUrl: false, promptAgent: false, openFile: false }, modelContext: false, statePersistence: "session" }); host.__artifacts = { onRequest: async () => { calls++; throw new Error("should not execute"); }, onFileRequest: async () => { calls++; }, onPluginCall: async () => { calls++; }, onAction: () => { calls++; } }; - expect(getArtifactCapabilities()).toEqual({ server: true, files: true, plugins: true, hostActions: true, statePersistence: "session" }); + expect(getArtifactCapabilities()).toEqual({ server: true, files: true, plugins: true, hostActions: true, actions: { openUrl: true, promptAgent: true, openFile: true }, modelContext: false, statePersistence: "session" }); host.__artifacts = { actionUrl: "/action", persistUrl: "/state" }; expect(getArtifactCapabilities()).toMatchObject({ hostActions: true, statePersistence: "persistent", server: false }); expect(calls).toBe(0); diff --git a/src/sdk/hooks.ts b/src/sdk/hooks.ts index 338846f..0c3224d 100644 --- a/src/sdk/hooks.ts +++ b/src/sdk/hooks.ts @@ -12,22 +12,32 @@ export type ArtifactAction = export type SetArtifactState = Dispatch>; +/** Small user-selected context, independent from durable app data or prompts. */ +export type ArtifactModelContext = { route?: string; selection?: Record; filters?: Record }; +export type ArtifactEnvironment = { locale?: string; timeZone?: string }; +export type ArtifactHostActions = { openUrl: boolean; promptAgent: boolean; openFile: boolean }; + export type ArtifactCapabilities = { server: boolean; files: boolean; plugins: boolean; hostActions: boolean; + actions: ArtifactHostActions; + modelContext: boolean; statePersistence: "persistent" | "session"; }; /** Query the bridges installed by this view without making a request. */ export function getArtifactCapabilities(): ArtifactCapabilities { const bridge = (globalThis as typeof globalThis & { __artifacts?: HostBridge }).__artifacts ?? {}; + const legacyActions = typeof bridge.onAction === "function" || !!bridge.actionUrl; + const actions = bridge.actions ?? { openUrl: legacyActions, promptAgent: legacyActions, openFile: legacyActions }; return { server: typeof bridge.onRequest === "function", files: typeof bridge.onFileRequest === "function", plugins: typeof bridge.onPluginCall === "function", - hostActions: typeof bridge.onAction === "function" || !!bridge.actionUrl, + hostActions: Object.values(actions).some(Boolean), actions, + modelContext: typeof bridge.onModelContext === "function", statePersistence: bridge.persistUrl ? "persistent" : "session", }; } @@ -44,8 +54,42 @@ export type HostBridge = { onPluginCall?: (request: PluginRequest) => Promise; onFileRequest?: (request: ArtifactFileRequest) => Promise; onFileDownload?: (url: string) => Promise; + actions?: ArtifactHostActions; + environment?: ArtifactEnvironment; + modelContext?: ArtifactModelContext | null; + contextAttached?: boolean; + onModelContext?: (context: ArtifactModelContext | null) => Promise; }; +export async function updateArtifactContext(context: ArtifactModelContext | null): Promise { + const bridge = hostBridge(); + if (!bridge.onModelContext) throw new Error("Model context is unavailable in this view"); + const json = JSON.stringify(context); + if (new TextEncoder().encode(json).byteLength > 16 * 1024) throw new Error("Artifact context exceeds 16 KiB"); + await bridge.onModelContext(JSON.parse(json) as ArtifactModelContext | null); +} + +/** Host attachment removals and locale updates are observable without polling. */ +export function useArtifactContext() { + const [, changed] = useState(0); + useEffect(() => { + const update = () => changed(value => value + 1); + window.addEventListener("artifact-host-context-change", update); + return () => window.removeEventListener("artifact-host-context-change", update); + }, []); + return { context: hostBridge().modelContext ?? null, attached: hostBridge().contextAttached ?? false, update: updateArtifactContext }; +} + +export function useHostEnvironment(): ArtifactEnvironment { + const [, changed] = useState(0); + useEffect(() => { + const update = () => changed(value => value + 1); + window.addEventListener("artifact-host-context-change", update); + return () => window.removeEventListener("artifact-host-context-change", update); + }, []); + return hostBridge().environment ?? {}; +} + function hostBridge(): HostBridge { if (typeof window === "undefined") { return {}; diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 54fed8d..43b5683 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -2,6 +2,7 @@ export type { ArtifactAction, SetArtifactState } from "./hooks"; export { useState, useReducer, useRef, useMemo, useCallback, useEffect } from "react"; export { useArtifactAction, useArtifactState, useHostTheme } from "./hooks"; export { getArtifactCapabilities, type ArtifactCapabilities } from "./hooks"; +export { updateArtifactContext, useArtifactContext, useHostEnvironment, type ArtifactModelContext, type ArtifactEnvironment, type ArtifactHostActions } from "./hooks"; export { artifactFetch, type ArtifactHttpRequest, type ArtifactHttpResponse } from "./server"; export { pluginCall, type PluginRequest } from "./plugins"; export { artifactFiles, MAX_ARTIFACTS_FILE_BYTES, type ArtifactFile, type ArtifactFileRequest, type ArtifactFileResult, type ArtifactFileList, type ArtifactFileTransfer } from "./files"; diff --git a/src/sdk/routing.tsx b/src/sdk/routing.tsx index 0fb0173..d703d09 100644 --- a/src/sdk/routing.tsx +++ b/src/sdk/routing.tsx @@ -4,7 +4,7 @@ import { MemoryRouter, Router, NavigationType, createPath, parsePath, type Locat export { Routes, Route, Outlet, Navigate, NavLink, useNavigate, useParams, useLocation, useSearchParams, useMatch, useResolvedPath } from "react-router"; export type { RouteObject, NavigateOptions, To } from "react-router"; -export type ArtifactRoute = { path: string; basePath: string; external: true }; +export type ArtifactRoute = { path: string; basePath: string; external: true; transport?: "mcp" }; /** The viewer owns browser history; embedded artifacts keep their own history. */ export function ArtifactRouter({ children }: { children: ReactNode }) { @@ -26,15 +26,51 @@ function HostedRouter({ route, children }: { route: ArtifactRoute; children: Rea || !value.path.startsWith("/") || !["POP", "PUSH", "REPLACE"].includes(value.action)) return; setCurrent({ location: { pathname: "/", search: "", hash: "", ...parsePath(value.path), state: value.state ?? null, key: String(value.key ?? "default") }, action: value.action }); }; + const local = (event: Event) => { + const value = (event as CustomEvent<{ path: string; action: NavigationType; state?: unknown }>).detail; + setCurrent({ location: { pathname: "/", search: "", hash: "", ...parsePath(value.path), state: value.state ?? null, key: String(Date.now()) }, action: value.action }); + }; + if (route.transport === "mcp") { + window.addEventListener("artifact/location", local); + return () => window.removeEventListener("artifact/location", local); + } window.addEventListener("message", receive); window.parent.postMessage({ type: "artifact/navigation-ready" }, "*"); return () => window.removeEventListener("message", receive); }, []); - const navigator = useMemo(() => ({ - createHref: to => route.basePath + (typeof to === "string" ? to : createPath(to)), - go: delta => window.parent.postMessage({ type: "artifact/navigate", action: "POP", delta }, "*"), - push: (to, state) => window.parent.postMessage({ type: "artifact/navigate", action: "PUSH", path: typeof to === "string" ? to : createPath(to), state }, "*"), - replace: (to, state) => window.parent.postMessage({ type: "artifact/navigate", action: "REPLACE", path: typeof to === "string" ? to : createPath(to), state }, "*"), - }), [route.basePath]); + const navigator = useMemo(() => { + if (route.transport === "mcp") { + const entries: Array<{ path: string; state?: unknown }> = [{ path: route.path }]; + let index = 0; + const navigate = (path: string, action: NavigationType, state?: unknown) => { + window.dispatchEvent(new CustomEvent("artifact/location", { detail: { path, action, state } })); + window.dispatchEvent(new CustomEvent("artifact/navigate", { detail: { path, action } })); + }; + return { + createHref: to => typeof to === "string" ? to : createPath(to), + go(delta) { + const next = Math.max(0, Math.min(entries.length - 1, index + delta)); + if (next !== index) { index = next; const entry = entries[index]!; navigate(entry.path, NavigationType.Pop, entry.state); } + }, + push(to, state) { + const path = typeof to === "string" ? to : createPath(to); + entries.splice(++index); + entries.push({ path, state }); + navigate(path, NavigationType.Push, state); + }, + replace(to, state) { + const path = typeof to === "string" ? to : createPath(to); + entries[index] = { path, state }; + navigate(path, NavigationType.Replace, state); + }, + } satisfies Navigator; + } + return { + createHref: to => route.basePath + (typeof to === "string" ? to : createPath(to)), + go: delta => window.parent.postMessage({ type: "artifact/navigate", action: "POP", delta }, "*"), + push: (to, state) => window.parent.postMessage({ type: "artifact/navigate", action: "PUSH", path: typeof to === "string" ? to : createPath(to), state }, "*"), + replace: (to, state) => window.parent.postMessage({ type: "artifact/navigate", action: "REPLACE", path: typeof to === "string" ? to : createPath(to), state }, "*"), + }; + }, [route.basePath, route.transport]); return {children}; }