From fff203f4c714d6dd8c4fb4c470874a31982d15bc Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 3 Oct 2026 20:13:18 -0700 Subject: [PATCH 1/2] Package the Artifacts MCP plugin --- README.md | 1 + docs/README.md | 1 + docs/mcp-plugin.md | 51 ++++++++++ package.json | 4 + plugins/artifacts/mcp.json | 10 ++ plugins/artifacts/plugin.json | 10 ++ plugins/artifacts/skills/author/SKILL.md | 16 +++ plugins/artifacts/skills/setup/SKILL.md | 14 +++ scripts/package.integration.test.ts | 7 ++ src/cli.ts | 17 +++- src/plugin-package.test.ts | 123 +++++++++++++++++++++++ src/plugin-package.ts | 73 ++++++++++++++ 12 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 docs/mcp-plugin.md create mode 100644 plugins/artifacts/mcp.json create mode 100644 plugins/artifacts/plugin.json create mode 100644 plugins/artifacts/skills/author/SKILL.md create mode 100644 plugins/artifacts/skills/setup/SKILL.md create mode 100644 src/plugin-package.test.ts create mode 100644 src/plugin-package.ts diff --git a/README.md b/README.md index 5837cf4..f5b4df0 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ inline views, targeted edits, history, and compatibility with older Canvas sourc Start with the [documentation index](docs/README.md) to find the guide for your task. - [Authentication, MCP sign-in, and access permissions](docs/authentication.md) +- [Portable MCP plugin and host surfaces](docs/mcp-plugin.md) - [Host commands and local persistence](docs/daemon.md) - [Deploy to Cloudflare](docs/cloudflare.md) - [Deploy celld on your own infrastructure](docs/celld-deployment.md) diff --git a/docs/README.md b/docs/README.md index 4528892..c67dc8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ then open [localhost:4786](http://127.0.0.1:4786). | --- | --- | | Run Artifacts on my machine | [Local host](daemon.md) or [Docker](docker.md) | | Sign in and connect my coding agent | [Authentication and MCP](authentication.md#connect-to-an-existing-deployment) | +| Install the portable agent plugin | [MCP plugin and host surfaces](mcp-plugin.md) | | Share with my team or publish a link | [Libraries and URL permissions](authentication.md#library-and-url-permissions) | | Work with source files in a local project | [File-based workspace](local-workspace.md) | diff --git a/docs/mcp-plugin.md b/docs/mcp-plugin.md new file mode 100644 index 0000000..d81dcb7 --- /dev/null +++ b/docs/mcp-plugin.md @@ -0,0 +1,51 @@ +# Artifacts MCP plugin + +Artifacts includes a portable [Agent Plugins 1.0.0](https://agent-plugins.org/) package in `plugins/artifacts`. The root `plugin.json` identifies the package, `mcp.json` configures the connection, and `skills/author` and `skills/setup` provide scoped agent instructions. Skills are discovered by directory convention. OpenAI onboarding is isolated under `extensions.com.openai.onboardingSkill`; other clients may ignore it. + +## Configure a package + +Generate a new package for a local artifact source directory: + +```sh +artifacts plugin --out ./artifacts-plugin --dir /absolute/path/to/project/artifacts +``` + +Or connect it to the MCP endpoint of your own existing deployment: + +```sh +artifacts plugin --out ./artifacts-plugin --url "$ARTIFACTS_MCP_URL" +``` + +Set `ARTIFACTS_MCP_URL` to the actual endpoint provided by your deployment operator. The command does not deploy a service, invent an endpoint, or establish authentication. Remote URLs require HTTPS; an explicit loopback HTTP URL is accepted for local development. URL credentials, query parameters, and fragments are rejected. Authenticate through the host's connection flow rather than adding credentials to the generated files. See [authentication](authentication.md) for deployment access boundaries. + +The destination must not exist and its parent directory must exist. Existing output is never overwritten. Choose a fresh directory for a changed configuration. `--url` and `--dir` are mutually exclusive. Without either, the generator captures the CLI's current default artifact directory into the generated stdio arguments. `--dir` names the source directory itself, not its parent project. The generator does not create or modify that workspace. + +The repository template uses `bunx --bun @sidequery/artifacts@0.1.0 mcp`. It requires Bun in the host's executable search path and access to that package version. Its unconfigured directory follows the CLI's environment/workspace defaults. Prefer a generated package with an explicit directory when a host's launch directory is unclear. Local stdio stores source files and history locally; it is separate from hosted libraries and does not execute hosted backends. See [local workspace](local-workspace.md) and [app backends](app-backends.md). + +Install the resulting directory through a client that supports portable Agent Plugins. Installation and distribution are client-specific; this package is not a marketplace registration. The portable schema uses `type: "streamable-http"` for remote transport. Registered OpenAI app `.app.json` server mappings are a different configuration surface and are not combined with `mcp.json`. + +## Host surfaces + +The server builds on MCP Apps and advertises optional OpenAI extensions. Actual availability depends on client support, advertised capabilities, deployment permissions, and authentication. Text-only clients retain ordinary tool results and diagnostics. + +| Surface | Purpose and boundary | +| --- | --- | +| Global sidebar | Open the Artifacts library through an empty-argument entrypoint; list only the connected identity's accessible artifacts. | +| Conversation view | Browse working artifacts in the connected workspace and keep the selected artifact context available in the host view. There is no stored host conversation identifier or separate conversation membership list. | +| Mentions | Search accessible artifacts for composer selection. Mention results do not grant access. | +| Deep links | Open `/artifact?workspace=WORKSPACE&name=NAME` or `/artifact?workspace=WORKSPACE&version_id=ID`, with optional `route` for internal navigation. Encode query values. Server authorization still applies; a link never bypasses owner/team checks. | +| Model context | Publish selected artifact/revision, route, and filter context without automatically sending a prompt. Prompt actions are deliberate user actions. | +| File editor | Open the owned `.artifact.tsx` format through opaque host resources. Writing requires writable capability and an ETag; conflicts require rereading before retrying. | +| Display and theme | Use the host's supported display modes, theme, styles, locale, and timezone; hide unavailable controls and tolerate rejected requests. | + +Tool visibility distinguishes model calls from app calls; it does not replace server authorization. Host context and file resource identifiers are untrusted input. UI selection, attachments, and cached state cannot expand library, owner, or team access. + +## Authoring and verification + +The author skill reads `artifact_guide`, guards source edits using `source_hash`/`expected_hash`, suppresses intermediate previews with `preview: false` when supported, and deliberately calls `artifact_open` when the result is ready. Validation diagnostics may accompany an applied edit; they are not a rollback guarantee. Compatible preview refreshes should preserve view state. + +Setup verifies connection using tool discovery, `artifact_guide`, and `artifact_list`. It does not create example artifacts or alter the user's library. Host file editing requires its own advertised resource and write capabilities; it is separate from hosted artifact file storage described in [files](files.md). + +Rich OpenAI forms and migration to multi-round-trip requests (MRTR) are deliberately deferred. Packaging this plugin does not claim support for those protocol extensions or for every surface in every host. + +The portable manifest and connection shapes follow the [plugin schema](https://agent-plugins.org/schemas/1.0.0/plugin.schema.json) and [MCP schema](https://agent-plugins.org/schemas/1.0.0/mcp.schema.json). Optional host integration follows the [OpenAI MCP Extensions specification](https://github.com/openai/mcp-extensions/blob/main/docs/spec.md); the installed SDK and capability negotiation determine the implemented contract. diff --git a/package.json b/package.json index eab43b1..bd7e1f8 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,10 @@ "src/gallery/subscription.ts", "src/gallery/local-subscriptions.ts", "src/gallery/folders.tsx", + "docs/mcp-plugin.md", + "docs/local-workspace.md", + "src/plugin-package.ts", + "plugins/artifacts/**", "src/mcp/host-contract.ts", "src/mcp/workspace-contract.ts", "src/mcp/file-contract.ts", diff --git a/plugins/artifacts/mcp.json b/plugins/artifacts/mcp.json new file mode 100644 index 0000000..263659d --- /dev/null +++ b/plugins/artifacts/mcp.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "artifacts": { + "type": "stdio", + "command": "bunx", + "args": ["--bun", "@sidequery/artifacts@0.1.0", "mcp"] + } + } +} diff --git a/plugins/artifacts/plugin.json b/plugins/artifacts/plugin.json new file mode 100644 index 0000000..caad7f7 --- /dev/null +++ b/plugins/artifacts/plugin.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "artifacts", + "version": "0.1.0", + "description": "Create, edit, and explore React artifacts in your configured Artifacts workspace.", + "author": { "name": "Sidequery" }, + "repository": "https://github.com/sidequery/artifacts", + "license": "MIT", + "extensions": { "com.openai": { "onboardingSkill": "./skills/setup/SKILL.md" } } +} diff --git a/plugins/artifacts/skills/author/SKILL.md b/plugins/artifacts/skills/author/SKILL.md new file mode 100644 index 0000000..d862f1a --- /dev/null +++ b/plugins/artifacts/skills/author/SKILL.md @@ -0,0 +1,16 @@ +--- +name: author +description: Create or edit React artifacts using the configured Artifacts MCP server; inspect existing artifacts, validate changes, and deliberately open the result. +--- + +Read `artifact_guide` once in the current conversation before authoring. It owns the SDK exports, allowed imports, runtime restrictions, and validation behavior. Use the connected server's current tool schemas rather than assuming every deployment exposes the same tools. + +For an existing artifact, read the relevant source with `artifact_read` before editing. Pass its `source_hash` as `expected_hash` to `artifact_edit`; on conflict, reread and reconcile the user's changes. Use exact replacements with unique matches. A failed validation may leave source applied: inspect the result and repair diagnostics instead of assuming rollback. + +Use `preview: false` for intermediate writes, edits, and restores when supported by the advertised tool schema. After the requested result passes validation, call `artifact_open` deliberately to show it. Preserve compatible view state and internal navigation. Do not send a user prompt merely to publish selection context; sending a prompt requires the user's action or request. + +Respect the connected identity, selected owner/library, team permissions, and workspace. A UI attachment, mention, deep link, or hidden tool is not authorization to access another owner's data. Read the current selection before mutation; ask when the intended target is materially ambiguous. Sharing or moving ownership requires the user's instruction. Never put credentials, transfer URLs, or secret values in source or plugin configuration. + +For hosted server, database, or file work, check current runtime capabilities and the SDK guide. File-based local stdio workspaces do not execute hosted backends. Do not replace durable storage with UI state. When editing a host file resource, retain its opaque URI, require writable capability, and use the current ETag; reread and reconcile conflicts. + +Finish with the artifact opened when the host supports it, validation status, and any concrete remaining limitation. A successful preview request does not prove the user viewed it. diff --git a/plugins/artifacts/skills/setup/SKILL.md b/plugins/artifacts/skills/setup/SKILL.md new file mode 100644 index 0000000..4a9f817 --- /dev/null +++ b/plugins/artifacts/skills/setup/SKILL.md @@ -0,0 +1,14 @@ +--- +name: setup +description: Verify an Artifacts plugin connection and explain the configured local workspace or user-owned remote deployment without creating sample work. +--- + +Inspect the packaged `../../mcp.json` relative to this skill directory. Determine whether it launches local stdio or connects to a configured remote endpoint. Use the host's supported plugin installation and authentication flow; do not assume one client's installation UI exists in another. + +For local stdio, Bun must be available to the host. An explicit `--dir` names the artifact source directory. Without it, the CLI resolves its configured environment or the launching workspace's `artifacts` directory (with legacy compatibility); the host's launch directory can differ from the user's project. If the target is unclear, establish the intended directory before creating work. The local workspace is separate from a hosted library and does not execute hosted backends. + +For remote MCP, use the endpoint of the user's existing Artifacts deployment. Require HTTPS except for an explicitly selected loopback HTTP development server. Never invent a public service URL or embed tokens in a manifest, URL, or skill. Complete authentication through normal host tooling and verify the current owner/team context. If authentication is unavailable, report the exact connection problem; do not hunt for credentials. + +Verify by discovering the advertised tools and calling `artifact_guide` and `artifact_list` with their current schemas. Do not write, remix, restore, or create a demonstration artifact as a connection check. If tools are unavailable, inspect the host's connection error and configured transport before changing anything. + +Report whether the connection succeeded, which workspace or deployment it uses, and which host surfaces are actually available. MCP Apps, sidebar and conversation views, mentions, deep links, and file editing depend on advertised capabilities and client support. Offer the author skill for the user's next artifact request; setup itself is complete after read-only verification. diff --git a/scripts/package.integration.test.ts b/scripts/package.integration.test.ts index 2849746..49e2760 100644 --- a/scripts/package.integration.test.ts +++ b/scripts/package.integration.test.ts @@ -230,6 +230,13 @@ try { `); await run([process.execPath, "run", mcpSmoke], clientDirectory, isolatedEnv); + const pluginDirectory = join(consumer, "artifacts-plugin"); + await run([artifact, "plugin", "--out", pluginDirectory, "--dir", artifacts], consumer, isolatedEnv); + const pluginManifest = JSON.parse(await Bun.file(join(pluginDirectory, "plugin.json")).text()); + expect(pluginManifest.name).toBe("artifacts"); + const pluginConfig = JSON.parse(await Bun.file(join(pluginDirectory, "mcp.json")).text()); + expect(pluginConfig.mcpServers.artifacts.args.slice(-2)).toEqual(["--dir", artifacts]); + if (process.env.CELLD_PACKAGE_INTEGRATION === "1") { const target = process.platform === "darwin" && process.arch === "arm64" ? "aarch64-apple-darwin" diff --git a/src/cli.ts b/src/cli.ts index dc078de..e3375ab 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,6 +14,7 @@ import { createArtifactServer } from "./serve"; import { ArtifactService } from "./service"; import { historyPath } from "./history"; import { PLUGIN_ROOT } from "./paths"; +import { writePluginPackage } from "./plugin-package"; import type { ProjectArchive } from "./project-archive-contract"; type ServerManager = Pick; @@ -117,6 +118,18 @@ async function main(): Promise { return; } + if (args.command === "plugin") { + if (args.positionals.length) throw new Error("plugin accepts options only: --out DIRECTORY [--url URL | --dir WORKSPACE]"); + for (const [name, value] of Object.entries(args.flags)) { + if (!["out", "url", "dir"].includes(name)) throw new Error(`Unknown plugin option: --${name}`); + if (value === true || !value.trim()) throw new Error(`--${name} requires a value`); + } + const directory = flagString(args.flags, "out"); + if (!directory) throw new Error("plugin requires --out DIRECTORY"); + printJson(await writePluginPackage({ directory, url: flagString(args.flags, "url"), workspace: flagString(args.flags, "dir") })); + return; + } + if (await runServerCommand(args)) return; const cwd = process.cwd(); @@ -320,6 +333,7 @@ Usage: artifacts serve [--dir PATH] [--port N] artifacts web [--port 4784] [--dir PATH] artifacts mcp + artifacts plugin --out DIRECTORY [--url URL | --dir WORKSPACE] artifacts history [NAME] artifacts show VERSION_ID [--source] artifacts open --version VERSION_ID [--event EVENT_ID] [--placement split|tab|zoomed|overlay] @@ -338,7 +352,8 @@ Usage: artifacts server start [--port 4786] [--at-login] artifacts server stop | status | logs [--lines 100] | uninstall -All commands accept --dir PATH and --history-db PATH. +Workspace commands accept --dir PATH and --history-db PATH. +plugin creates a portable package in a new directory; its parent must exist. read returns up to 200 lines by default, with a source_hash and next_line. edit accepts JSON: {"edits":[{"old_text":"exact match","new_text":"replacement"}],"expected_hash":"optional SHA-256"}. Edits run sequentially in memory; every old_text must match exactly once. Invalid batches diff --git a/src/plugin-package.test.ts b/src/plugin-package.test.ts new file mode 100644 index 0000000..0dcfd69 --- /dev/null +++ b/src/plugin-package.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as filesystem from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; +import { artifactsDirFrom } from "./artifactFile"; +import { writePluginPackage } from "./plugin-package"; + +const temporaryDirectories: string[] = []; +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); +}); +async function destination() { + const root = await mkdtemp(join(tmpdir(), "artifacts-plugin-")); + temporaryDirectories.push(root); + return join(root, "plugin"); +} +async function config(directory: string) { + return JSON.parse(await readFile(join(directory, "mcp.json"), "utf8")); +} + +describe("portable plugin package", () => { + test("materializes discoverable skills and a configured local stdio transport", async () => { + const directory = await destination(); + const workspace = join(directory, "..", "source files"); + expect(await writePluginPackage({ directory, workspace })).toEqual({ directory }); + const manifest = JSON.parse(await readFile(join(directory, "plugin.json"), "utf8")); + expect(manifest.$schema).toBe("https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"); + expect(manifest.name).toBe("artifacts"); + expect(Object.keys(manifest).sort()).toEqual(["$schema", "author", "description", "extensions", "license", "name", "repository", "version"]); + const onboarding = manifest.extensions["com.openai"].onboardingSkill; + expect(onboarding).toBe("./skills/setup/SKILL.md"); + expect(await readFile(join(directory, onboarding), "utf8")).toContain("name: setup"); + expect(await readFile(join(directory, "skills/author/SKILL.md"), "utf8")).toContain("name: author"); + expect((await readdir(directory)).sort()).toEqual(["mcp.json", "plugin.json", "skills"]); + expect(await config(directory)).toEqual({ + $schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + mcpServers: { artifacts: { type: "stdio", command: "bunx", args: ["--bun", "@sidequery/artifacts@0.1.0", "mcp", "--dir", resolve(workspace)] } }, + }); + }); + + test("captures the CLI's actual default source directory", async () => { + const directory = await destination(); + await writePluginPackage({ directory }); + expect((await config(directory)).mcpServers.artifacts.args.slice(-2)).toEqual(["--dir", resolve(artifactsDirFrom(process.cwd()))]); + }); + + test.each(["https://artifacts.example.test/mcp", "http://127.0.0.1:4786/mcp", "http://localhost:4786/mcp", "http://[::1]:4786/mcp"])("uses remote transport for explicit endpoint %s", async url => { + const directory = await destination(); + await writePluginPackage({ directory, url }); + expect((await config(directory)).mcpServers.artifacts).toEqual({ type: "streamable-http", url }); + }); + + test.each(["http://artifacts.example.test/mcp", "https://user:secret@example.test/mcp", "https://example.test/mcp?token=secret", "https://example.test/mcp#secret", "file:///tmp/server", "not-a-url", ""])("rejects unsafe or invalid URL %s before creating output", async url => { + const directory = await destination(); + await expect(writePluginPackage({ directory, url })).rejects.toThrow(); + expect(await readdir(join(directory, ".."))).toEqual([]); + }); + + test("refuses ambiguous local and remote configuration", async () => { + await expect(writePluginPackage({ directory: await destination(), workspace: "/tmp/artifacts", url: "https://example.test/mcp" })).rejects.toThrow("not both"); + }); + + test("preserves existing files and symlink destinations", async () => { + const directory = await destination(); + await mkdir(directory); + await writeFile(join(directory, "user.txt"), "keep me"); + await expect(writePluginPackage({ directory })).rejects.toThrow(); + expect(await readFile(join(directory, "user.txt"), "utf8")).toBe("keep me"); + expect(await readdir(directory)).toEqual(["user.txt"]); + const link = join(directory, "..", "link"); + await symlink(directory, link); + await expect(writePluginPackage({ directory: link })).rejects.toThrow(); + expect(await readdir(directory)).toEqual(["user.txt"]); + }); + + test("cleans its own partial output after a write failure", async () => { + const directory = await destination(); + const originalWrite = filesystem.writeFile; + const write = spyOn(filesystem, "writeFile").mockImplementation(async (...args) => { + if (args[0] === join(directory, "mcp.json")) throw new Error("simulated disk failure"); + return originalWrite(...args); + }); + try { + await expect(writePluginPackage({ directory })).rejects.toThrow("simulated disk failure"); + expect(await readdir(join(directory, ".."))).toEqual([]); + } finally { + write.mockRestore(); + } + }); + + test("concurrent attempts have one winner and preserve its complete output", async () => { + const directory = await destination(); + const results = await Promise.allSettled([writePluginPackage({ directory }), writePluginPackage({ directory })]); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter(result => result.status === "rejected")).toHaveLength(1); + expect((await config(directory)).mcpServers.artifacts.type).toBe("stdio"); + expect(await readFile(join(directory, "skills/author/SKILL.md"), "utf8")).toContain("name: author"); + }); +}); + +test("CLI generates remote/local packages without initializing a workspace and rejects incomplete flags", async () => { + const directory = await destination(); + const root = join(directory, ".."); + const cli = fileURLToPath(new URL("./cli.ts", import.meta.url)); + const run = async (args: string[]) => { + const child = Bun.spawn([process.execPath, cli, "plugin", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); + return { exitCode, stdout, stderr }; + }; + const remote = await run(["--out", directory, "--url", "https://example.test/mcp"]); + expect(remote.exitCode).toBe(0); + expect(JSON.parse(remote.stdout)).toEqual({ directory }); + expect((await config(directory)).mcpServers.artifacts).toEqual({ type: "streamable-http", url: "https://example.test/mcp" }); + const local = await run(["--out", join(root, "local"), "--dir", join(root, "source")]); + expect(local.exitCode).toBe(0); + expect((await config(join(root, "local"))).mcpServers.artifacts.args.slice(-2)).toEqual(["--dir", join(root, "source")]); + const missing = await run(["--out", join(root, "invalid"), "--url"]); + expect(missing.exitCode).toBe(1); + expect(missing.stderr).toContain("--url requires a value"); + expect((await readdir(root)).sort()).toEqual(["local", "plugin"]); +}); diff --git a/src/plugin-package.ts b/src/plugin-package.ts new file mode 100644 index 0000000..94ee0a6 --- /dev/null +++ b/src/plugin-package.ts @@ -0,0 +1,73 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, open, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { artifactsDirFrom } from "./artifactFile"; +import { PLUGIN_ROOT } from "./paths"; + +const TEMPLATE_FILES = ["plugin.json", "mcp.json", "skills/author/SKILL.md", "skills/setup/SKILL.md"] as const; + +function endpointUrl(value: string): string { + const url = new URL(value); + const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname); + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error("MCP URL must use HTTPS, except explicit loopback HTTP development endpoints"); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("MCP URL must not contain credentials, query parameters, or a fragment"); + } + return url.href; +} + +/** Materialize a plugin in a new directory whose parent already exists. */ +export async function writePluginPackage(options: { + directory: string; + url?: string; + /** Artifact source directory, equivalent to `artifacts mcp --dir`. */ + workspace?: string; +}): Promise<{ directory: string }> { + if (!options.directory.trim()) throw new Error("Plugin output directory must not be empty"); + if (options.url !== undefined && options.workspace !== undefined) { + throw new Error("Choose a remote MCP URL or a local workspace, not both"); + } + if (options.workspace !== undefined && !options.workspace.trim()) throw new Error("Workspace must not be empty"); + const url = options.url === undefined ? undefined : endpointUrl(options.url); + const directory = resolve(options.directory); + const templateRoot = join(PLUGIN_ROOT, "plugins/artifacts"); + // Reject symlinks in directory components as well as the individual files. + for (const path of ["plugins", "plugins/artifacts", "plugins/artifacts/skills", "plugins/artifacts/skills/author", "plugins/artifacts/skills/setup"]) { + if (!(await lstat(join(PLUGIN_ROOT, path))).isDirectory()) { + throw new Error(`Plugin template is not a regular directory: ${path}`); + } + } + // Fixed paths and O_NOFOLLOW avoid copying arbitrary files or template symlinks. + const files = await Promise.all(TEMPLATE_FILES.map(async path => { + const handle = await open(join(templateRoot, path), constants.O_RDONLY | constants.O_NOFOLLOW); + try { + if (!(await handle.stat()).isFile()) throw new Error(`Plugin template is not a regular file: ${path}`); + return { path, contents: await handle.readFile("utf8") }; + } finally { + await handle.close(); + } + })); + const config = JSON.parse(files.find(file => file.path === "mcp.json")!.contents); + if (url) { + config.mcpServers.artifacts = { type: "streamable-http", url }; + } else { + config.mcpServers.artifacts.args.push("--dir", resolve(options.workspace ?? artifactsDirFrom(process.cwd()))); + } + files.find(file => file.path === "mcp.json")!.contents = JSON.stringify(config, null, 2) + "\n"; + + // A non-recursive mkdir is the exclusive claim. Never clean an existing destination. + await mkdir(directory); + try { + for (const file of files) { + const target = join(directory, file.path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, file.contents, { flag: "wx" }); + } + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } + return { directory }; +} From 6356b86788ecddce589ab65efbcaa1d73cb853be Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Sat, 3 Oct 2026 20:29:31 -0700 Subject: [PATCH 2/2] Resolve legacy artifacts in MCP previews --- src/mcp/app.test.ts | 14 ++++++++++++++ src/mcp/app.ts | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/mcp/app.test.ts b/src/mcp/app.test.ts index 562f7b5..8d82d12 100644 --- a/src/mcp/app.test.ts +++ b/src/mcp/app.test.ts @@ -89,6 +89,20 @@ test("working preview initializes from sidecar without modifying it and rejects await expect(artifactAppResult(service, { name: "linked" })).rejects.toThrow(); }); +test("library previews resolve legacy names and preserve their sidecar state", async () => { + const { dir, call } = fixture(); + writeFileSync(join(dir, "legacy.canvas.tsx"), VALID_ARTIFACT); + writeFileSync(join(dir, "legacy.canvas.data.json"), '{"count":7}'); + const library = await call("artifacts_library", {}); + const name = (library.structuredContent.artifacts as Array<{ name: string }>)[0]!.name; + for (const tool of ["artifacts_preview", "artifact_open"]) { + const preview = await call(tool, { name }); + expect(preview.isError).toBe(false); + expect(preview._meta?.artifact?.name).toBe("legacy"); + expect(preview._meta?.artifact?.state).toEqual({ count: 7 }); + } +}); + 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" }, { capabilities: { extensions: { "io.modelcontextprotocol/ui": { mimeTypes: [ARTIFACTS_APP_MIME] } } } }); diff --git a/src/mcp/app.ts b/src/mcp/app.ts index 0c5154d..8a0f88a 100644 --- a/src/mcp/app.ts +++ b/src/mcp/app.ts @@ -62,7 +62,8 @@ export async function artifactAppResult(service: ArtifactService, selection: { n 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; - const path = saved?.source_path ?? service.resolve(ensureArtifactFileName(selection.name!)); + if (!saved) ensureArtifactFileName(selection.name!); + const path = saved?.source_path ?? service.resolve(selection.name!); if (!saved) assertRegularArtifact(path); const source = saved?.source ?? readFileSync(path, "utf8"); const project = saved?.project ?? readLocalProject(path); @@ -77,7 +78,7 @@ export async function artifactAppResult(service: ArtifactService, selection: { n if (event) state = JSON.parse(event.initial_state); } else { try { - const value: unknown = JSON.parse(readFileSync(path.replace(/\.artifact\.tsx$/, ".artifact.data.json"), "utf8")); + const value: unknown = JSON.parse(readFileSync(path.replace(/\.(artifact|canvas)\.tsx$/, ".$1.data.json"), "utf8")); if (value && typeof value === "object" && !Array.isArray(value)) state = value as Record; } catch { /* Match gallery previews: malformed sidecars remain untouched. */ } }