Add normal artifact creation via MCP and CLI (#2057)

This commit is contained in:
Caprika 2026-05-18 17:50:38 +08:00 committed by GitHub
parent 0101a09b10
commit 7975514b3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1034 additions and 112 deletions

46
CONTEXT.md Normal file
View file

@ -0,0 +1,46 @@
# Open Design
Open Design is a local-first design workspace where projects contain generated design files and agent conversations. This glossary records domain language only, not implementation details.
## Language
**Project**:
A top-level design workspace that contains conversations and design files.
_Avoid_: repo, folder, session
**Normal Artifact**:
A project design output represented by an artifact entry file and its artifact manifest.
_Avoid_: live artifact, generic file upload
**Live Artifact**:
A refreshable project design output stored as a live-artifact record with source data and preview state.
_Avoid_: normal artifact, static artifact
**Artifact Entry File**:
The primary project file that opens or renders a normal artifact.
_Avoid_: support file, asset, sidecar
**Artifact Manifest**:
The sidecar metadata that identifies a project file as a normal artifact and records its kind, renderer, exports, and entry file.
_Avoid_: live-artifact document, project metadata
**Active Project**:
The project the user most recently interacted with in the Open Design UI and that MCP tools may use when no project is specified.
_Avoid_: latest project, default project
## Relationships
- A **Project** contains zero or more **Normal Artifacts**.
- A **Normal Artifact** has exactly one **Artifact Entry File**.
- A **Normal Artifact** has exactly one **Artifact Manifest**.
- A **Live Artifact** belongs to a **Project** but is distinct from a **Normal Artifact**.
- An **Active Project** can be used as the target for MCP operations when the caller omits an explicit **Project**.
## Example dialogue
> **Dev:** "When a coding agent creates a Codex deck through MCP, should it create a live artifact?"
> **Domain expert:** "No. Unless the user asked for refreshable data, create a **Normal Artifact**: write the **Artifact Entry File** and persist its **Artifact Manifest** in the **Active Project**."
## Flagged ambiguities
- "artifact creation" was used to mean both **Normal Artifact** creation and **Live Artifact** creation; resolved: this capability creates **Normal Artifacts** only.

View file

@ -0,0 +1,119 @@
import { Buffer } from 'node:buffer';
import { inferLegacyManifest, validateArtifactManifestInput } from './artifact-manifest.js';
type JsonObject = Record<string, unknown>;
export interface CreateProjectArtifactInput {
name: string;
content: string;
encoding?: 'utf8' | 'base64' | string;
artifactManifest?: unknown;
}
export interface CreateProjectArtifactOptions {
projectsRoot: string;
projectId: string;
input: CreateProjectArtifactInput;
metadata?: unknown;
writeProjectFile: (
projectsRoot: string,
projectId: string,
name: string,
body: Buffer,
options: { overwrite: false; artifactManifest: unknown },
metadata?: unknown,
) => Promise<unknown>;
}
export class ArtifactManifestRequiredError extends Error {
code = 'ARTIFACT_MANIFEST_REQUIRED' as const;
constructor(name: string) {
super(`artifactManifest is required for ${name}; no safe default manifest can be inferred`);
}
}
export class ArtifactManifestInvalidError extends Error {
code = 'ARTIFACT_MANIFEST_INVALID' as const;
constructor(message: string) {
super(`invalid artifactManifest: ${message}`);
}
}
export function buildCreateArtifactRequestBody(input: CreateProjectArtifactInput): JsonObject {
return {
name: input.name,
content: input.content,
encoding: input.encoding === 'base64' ? 'base64' : 'utf8',
artifact: true,
overwrite: false,
...(input.artifactManifest === undefined ? {} : { artifactManifest: input.artifactManifest }),
};
}
export function resolveCreateArtifactManifest(input: CreateProjectArtifactInput): unknown {
const manifest = input.artifactManifest !== undefined && input.artifactManifest !== null
? input.artifactManifest
: inferLegacyManifest(input.name);
if (manifest) {
const validated = validateArtifactManifestInput(manifest, input.name);
if (!validated.ok) {
throw new ArtifactManifestInvalidError(validated.error);
}
return validated.value;
}
throw new ArtifactManifestRequiredError(input.name);
}
export async function createProjectArtifactFile(options: CreateProjectArtifactOptions): Promise<unknown> {
const { input } = options;
const body = input.encoding === 'base64'
? Buffer.from(input.content, 'base64')
: Buffer.from(input.content, 'utf8');
return await options.writeProjectFile(
options.projectsRoot,
options.projectId,
input.name,
body,
{
overwrite: false,
artifactManifest: resolveCreateArtifactManifest(input),
},
options.metadata,
);
}
export async function postCreateArtifactRequest(args: {
baseUrl: string;
projectId: string;
input: CreateProjectArtifactInput;
}): Promise<unknown> {
const response = await fetch(
`${args.baseUrl.replace(/\/$/, '')}/api/projects/${encodeURIComponent(args.projectId)}/files`,
{
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(buildCreateArtifactRequestBody(args.input)),
},
);
const text = await response.text();
let body: unknown = text;
if (text.length > 0) {
try {
body = JSON.parse(text) as unknown;
} catch {
body = { message: text };
}
}
if (!response.ok) {
const error = new Error(`daemon artifact endpoint failed with ${response.status}`);
(error as Error & { details?: unknown; status?: number }).details = body;
(error as Error & { status?: number }).status = response.status;
throw error;
}
return body;
}

View file

@ -0,0 +1,152 @@
import { readFile } from 'node:fs/promises';
import { postCreateArtifactRequest } from './artifact-create.js';
import { resolveDaemonUrl } from './daemon-url.js';
import { resolveProjectArg, withActiveEcho } from './mcp.js';
type JsonObject = Record<string, unknown>;
interface ArtifactCliResult {
exitCode: number;
}
interface ParsedOptions {
command: string | undefined;
project?: string;
name?: string;
inputPath?: string;
manifestPath?: string;
daemonUrl?: string;
encoding: 'utf8' | 'base64';
help: boolean;
}
const USAGE = `Usage:
od artifacts create --name <path> --input <file> [--project <id-or-name>] [--manifest artifact.json] [--encoding utf8|base64] [--daemon-url <url>]
Creates one normal Open Design project artifact entry file through the local daemon.
When --project is omitted, the active Open Design project is used.
Existing target paths are rejected.
`;
function writeJson(value: unknown, stream: NodeJS.WriteStream = process.stdout): void {
stream.write(`${JSON.stringify(value)}\n`);
}
function fail(message: string, details?: unknown, status?: number): ArtifactCliResult {
writeJson(
{
ok: false,
...(status === undefined ? {} : { status }),
error: { message, ...(details === undefined ? {} : { details }) },
},
process.stderr,
);
return { exitCode: 1 };
}
function parseOptions(args: string[]): ParsedOptions | { error: string } {
const [command, ...rest] = args;
const options: ParsedOptions = {
command: command === '-h' || command === '--help' ? undefined : command,
encoding: 'utf8',
help: command === '-h' || command === '--help',
};
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index];
if (arg === '--project') {
const value = rest[++index];
if (!value) return { error: '--project requires a value' };
options.project = value;
} else if (arg === '--name') {
const value = rest[++index];
if (!value) return { error: '--name requires a path' };
options.name = value;
} else if (arg === '--input') {
const value = rest[++index];
if (!value) return { error: '--input requires a file path' };
options.inputPath = value;
} else if (arg === '--manifest') {
const value = rest[++index];
if (!value) return { error: '--manifest requires a file path' };
options.manifestPath = value;
} else if (arg === '--daemon-url') {
const value = rest[++index];
if (!value) return { error: '--daemon-url requires a URL' };
options.daemonUrl = value;
} else if (arg === '--encoding') {
const value = rest[++index];
if (value !== 'utf8' && value !== 'base64') return { error: '--encoding must be utf8 or base64' };
options.encoding = value;
} else if (arg === '-h' || arg === '--help') {
options.help = true;
} else {
return { error: `unknown option: ${arg}` };
}
}
return options;
}
async function readJsonObject(filePath: string): Promise<JsonObject> {
const text = await readFile(filePath, 'utf8');
let value: unknown;
try {
value = JSON.parse(text) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`invalid JSON in ${filePath}: ${message}`);
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${filePath} must contain a JSON object`);
}
return value as JsonObject;
}
export async function runArtifactsCli(args: string[]): Promise<ArtifactCliResult> {
const options = parseOptions(args);
if ('error' in options) return fail(options.error);
if (options.help || !options.command) {
process.stdout.write(USAGE);
return { exitCode: options.command ? 0 : 1 };
}
if (options.command !== 'create') return fail(`unknown artifacts command: ${options.command}`);
if (!options.name) return fail('create requires --name <path>');
if (!options.inputPath) return fail('create requires --input <file>');
try {
const daemonUrl = await resolveDaemonUrl(
options.daemonUrl === undefined ? {} : { flagUrl: options.daemonUrl },
);
const { id, resolved, active } = await resolveProjectArg(daemonUrl, options.project);
const fileBuffer = await readFile(options.inputPath);
const content = options.encoding === 'base64' ? fileBuffer.toString('base64') : fileBuffer.toString('utf8');
const artifactManifest = options.manifestPath === undefined
? undefined
: await readJsonObject(options.manifestPath);
const response = await postCreateArtifactRequest({
baseUrl: daemonUrl,
projectId: id,
input: {
name: options.name,
content,
encoding: options.encoding,
...(artifactManifest === undefined ? {} : { artifactManifest }),
},
});
const payload = response && typeof response === 'object' && !Array.isArray(response)
? (response as JsonObject)
: { result: response };
writeJson({ ok: true, ...withActiveEcho(payload, active, resolved) });
return { exitCode: 0 };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const details = error && typeof error === 'object' && 'details' in error
? (error as { details?: unknown }).details
: undefined;
const status = error && typeof error === 'object' && 'status' in error
? (error as { status?: number }).status
: undefined;
return fail(message, details, status);
}
}

View file

@ -2,6 +2,7 @@
// @ts-nocheck
import { runDaemonCliStartup } from './daemon-startup.js';
import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js';
import { runArtifactsCli } from './artifacts-cli.js';
import { runConnectorsToolCli } from './tools-connectors-cli.js';
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
import { splitResearchSubcommand } from './research/cli-args.js';
@ -170,6 +171,7 @@ const PLUGIN_LIST_BOOLEAN_FLAGS = new Set([
]);
const SUBCOMMAND_MAP = {
artifacts: runArtifacts,
media: runMedia,
mcp: runMcp,
research: runResearch,
@ -242,6 +244,9 @@ function printRootHelp() {
od tools live-artifacts <create|list|update|refresh> [options]
Manage live artifacts through daemon wrapper commands.
od artifacts create --name <path> --input <file> [--project <id-or-name>]
Create a normal project artifact through the local daemon.
od tools connectors <list|execute> [options]
Discover and execute configured connectors.
@ -266,11 +271,11 @@ function printRootHelp() {
and OD_PROJECT_ID from the env that the daemon injected on spawn.
od mcp [--daemon-url <url>]
Run a stdio MCP server that proxies read-only tool calls to a
Run a stdio MCP server that proxies project tool calls to a
running Open Design daemon. Wire it into a coding agent
(Claude Code, Cursor, VS Code, Zed, Windsurf) in another repo
to pull files from a local Open Design project without
exporting a zip.
to pull files from a local Open Design project and create
project-scoped artifacts without exporting a zip.
Options:
--port <n> Port to listen on (default: 7456, env: OD_PORT).
@ -348,6 +353,11 @@ async function runResearchSearch(rawArgs) {
process.stdout.write(`${await resp.text()}\n`);
}
async function runArtifacts(args) {
const { exitCode } = await runArtifactsCli(args);
process.exit(exitCode);
}
function printResearchHelp() {
console.log(`Usage:
od research search --query <text> [--max-sources 5] [--daemon-url <url>]
@ -746,10 +756,11 @@ async function runMcp(args) {
function printMcpHelp() {
console.log(`Usage: od mcp [--daemon-url <url>]
Run a stdio MCP (Model Context Protocol) server that proxies read-only
Run a stdio MCP (Model Context Protocol) server that proxies project
tool calls to a running Open Design daemon. Wire it into a coding agent
in another repo so the agent can pull files from a local Open Design
project without exporting a zip every iteration.
project and create project-scoped artifacts without exporting a zip
every iteration.
Options:
--daemon-url <url> Open Design daemon HTTP base URL. Resolution
@ -770,17 +781,18 @@ Tools exposed:
get_file([project, path]) file contents (textual mimes only for now)
search_files(query[, project]) literal substring search across textual files
list_files([project]) project files + artifactManifest sidecars
create_artifact(name, content) create one normal artifact entry file
When project is omitted, get_artifact / get_project / get_file /
search_files / list_files default to the project the user has open in
Open Design; get_artifact and get_file additionally default to the
active file. The response stamps usedActiveContext so callers can see
which project/file got resolved.
search_files / list_files / create_artifact default to the project the
user has open in Open Design; get_artifact and get_file additionally
default to the active file. The response stamps usedActiveContext so
callers can see which project/file got resolved.
For the copy-paste, per-client snippet (with absolute paths resolved
for your machine, plus a one-click deeplink for Cursor), open Settings
MCP server in the Open Design app. Read-only by design; the daemon
must be running locally for tool calls to succeed.`);
MCP server in the Open Design app. The daemon must be running locally
for tool calls to succeed.`);
}
// ---------------------------------------------------------------------------

View file

@ -1,7 +1,8 @@
// `od mcp` - stdio MCP server that proxies read-only tool calls to the
// `od mcp` - stdio MCP server that proxies project tool calls to the
// running daemon's HTTP API. Lets a coding agent in a *different* repo
// (Claude Code, Cursor, Zed) pull files from a local Open Design
// project without the export-zip-import dance.
// project and create project-scoped artifacts without the
// export-zip-import dance.
//
// The server itself holds no state and never touches the filesystem;
// every tool resolves to a fetch() against `OD_DAEMON_URL`. Spawn the
@ -17,6 +18,7 @@ import {
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { postCreateArtifactRequest } from './artifact-create.js';
const SERVER_NAME = 'open-design';
const SERVER_VERSION = '0.2.0';
@ -31,9 +33,9 @@ interface ProjectSummary { id: string; name: string; metadata?: JsonObject }
interface ProjectsPayload { projects?: ProjectSummary[] }
interface ProjectPayload { project?: ProjectSummary; id?: string; name?: string; metadata?: JsonObject }
interface ActiveContext { active?: boolean; projectId?: string; projectName?: string | null; fileName?: string | null; ageMs?: number | null }
type ResolvedProject = { id: string; name: string; source: 'uuid' | 'exact' | 'slug' | 'substring' };
type ResolvedProject = { id: string; name: string; source: 'uuid' | 'id' | 'exact' | 'slug' | 'substring' };
interface ProjectListCache { baseUrl: string; t: number; list: ProjectSummary[] }
interface McpArgs extends JsonObject { project?: unknown; entry?: unknown; include?: unknown; maxBytes?: unknown; path?: unknown; offset?: unknown; limit?: unknown; since?: unknown; query?: unknown; pattern?: unknown; max?: unknown }
interface McpArgs extends JsonObject { project?: unknown; entry?: unknown; include?: unknown; maxBytes?: unknown; path?: unknown; offset?: unknown; limit?: unknown; since?: unknown; query?: unknown; pattern?: unknown; max?: unknown; name?: unknown; content?: unknown; encoding?: unknown; artifactManifest?: unknown }
interface ProjectFileBundleEntry { name: string; mime: string; size: number | null; content: string | null; binary: boolean }
interface BundleInput { project: ProjectPayload | ProjectSummary; entry: string; files: ProjectFileBundleEntry[]; truncated: boolean; active: ActiveContext | null; resolved?: ResolvedProject | null }
interface ErrorWithCode { message?: string; code?: string; cause?: { code?: string } }
@ -63,6 +65,13 @@ const READ_ANNOTATIONS = {
openWorldHint: false,
};
const WRITE_ANNOTATIONS = {
readOnlyHint: false,
idempotentHint: false,
destructiveHint: false,
openWorldHint: false,
};
// Description style: short, one purpose-line per tool. Active-context
// fallback is documented once in the server `instructions` block, so
// per-tool descriptions just say "project optional" and don't repeat
@ -195,6 +204,38 @@ const TOOL_DEFS = [
},
annotations: { ...READ_ANNOTATIONS, title: 'List project files' },
},
{
name: 'create_artifact',
description:
'Create one normal Open Design project artifact entry file. Writes name+content, rejects existing targets, and persists artifactManifest when supplied. HTML, Markdown, and SVG entries get a default manifest when omitted. Project optional; defaults to the active project.',
inputSchema: {
type: 'object',
properties: {
project: PROJECT_ARG,
name: {
type: 'string',
description: 'Output path relative to the project root, for example "codex-product/index.html" or "deck.html".',
},
content: {
type: 'string',
description: 'Entry file contents. Use encoding="base64" for base64 content.',
},
encoding: {
type: 'string',
enum: ['utf8', 'base64'],
description: 'utf8 (default) | base64',
},
artifactManifest: {
type: 'object',
additionalProperties: true,
description: 'Optional ArtifactManifest sidecar. If omitted, Open Design infers one for HTML, Markdown, or SVG entry files.',
},
},
required: ['name', 'content'],
additionalProperties: false,
},
annotations: { ...WRITE_ANNOTATIONS, title: 'Create Open Design artifact' },
},
// Catalog (skills, design systems) is intentionally NOT exposed as
// MCP tools. Skills are recipes that Open Design itself uses to
// generate artifacts; an external coding agent consuming Open
@ -237,6 +278,9 @@ export async function runMcpStdio({ daemonUrl }: RunMcpOptions): Promise<void> {
' - search_files(query) to find a class/component/copy string',
' without fetching every file.',
' - list_files for metadata only.',
' - create_artifact(name, content) to create one normal artifact',
' entry file in the active or specified project. It rejects',
' existing targets and can accept an artifactManifest sidecar.',
' - list_projects to discover what is available on this daemon.',
' - get_active_context() if you want the active project/file',
' explicitly without making any other tool call.',
@ -341,88 +385,7 @@ export async function runMcpStdio({ daemonUrl }: RunMcpOptions): Promise<void> {
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const name = req.params?.name;
const args: McpArgs = (req.params?.arguments ?? {}) as McpArgs;
try {
switch (name) {
case 'list_projects':
return ok(await getJson<ProjectsPayload>(`${baseUrl}/api/projects`));
case 'get_active_context': {
const data = await getJson<ActiveContext>(`${baseUrl}/api/active`);
if (!data || data.active === false) {
return ok({
active: false,
hint: 'Open Design has no active project right now. The active context expires about 5 minutes after the last user interaction with Open Design, so the user may need to click into a project (or switch tabs inside one) to wake it up. Alternatively, pass project="<id-or-name>" to other tools to bypass active context entirely.',
});
}
return ok(data);
}
case 'get_project': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const data = await getJson<ProjectPayload>(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const project = data?.project ?? data;
return ok(
withActiveEcho(
{
...project,
entryFile: project?.metadata?.entryFile ?? null,
kind: project?.metadata?.kind ?? null,
},
active,
resolved,
),
);
}
case 'list_files': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const params = new URLSearchParams();
if (typeof args.since === 'number' && Number.isFinite(args.since)) params.set('since', String(args.since));
const qs = params.toString();
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/files${qs ? `?${qs}` : ''}`;
return ok(withActiveEcho(await getJson(url), active, resolved));
}
case 'get_file': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
let path = typeof args.path === 'string' ? args.path : '';
// When both project and path are omitted, fall back to the
// active file. The agent saying "read this file" without
// specifying anything is the most natural call site.
if (!path && active && active.fileName) {
path = active.fileName;
}
requireString(path, 'path');
const offset = typeof args.offset === 'number' && Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0;
const limit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.max(1, Math.floor(args.limit)) : 2000;
return await getFile(baseUrl, id, path, active, resolved, offset, limit);
}
case 'get_artifact':
return await getArtifact(
baseUrl,
args.project,
args.entry,
args.include,
args.maxBytes,
);
case 'search_files': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
requireString(args.query, 'query');
const params = new URLSearchParams({ q: String(args.query) });
if (args.pattern) params.set('pattern', String(args.pattern));
if (args.max) params.set('max', String(args.max));
return ok(
withActiveEcho(
await getJson(
`${baseUrl}/api/projects/${encodeURIComponent(id)}/search?${params.toString()}`,
),
active,
resolved,
),
);
}
default:
return errorResult(`unknown tool: ${name}`);
}
} catch (err) {
return errorResult(formatError(err, baseUrl));
}
return handleMcpToolCall(baseUrl, name, args);
});
const transport = new StdioServerTransport();
@ -456,6 +419,122 @@ function requireString(v: unknown, name: string): asserts v is string {
}
}
async function handleMcpToolCall(baseUrl: string, name: unknown, args: McpArgs) {
try {
switch (name) {
case 'list_projects':
return ok(await getJson<ProjectsPayload>(`${baseUrl}/api/projects`));
case 'get_active_context': {
const data = await getJson<ActiveContext>(`${baseUrl}/api/active`);
if (!data || data.active === false) {
return ok({
active: false,
hint: 'Open Design has no active project right now. The active context expires about 5 minutes after the last user interaction with Open Design, so the user may need to click into a project (or switch tabs inside one) to wake it up. Alternatively, pass project="<id-or-name>" to other tools to bypass active context entirely.',
});
}
return ok(data);
}
case 'get_project': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const data = await getJson<ProjectPayload>(`${baseUrl}/api/projects/${encodeURIComponent(id)}`);
const project = data?.project ?? data;
return ok(
withActiveEcho(
{
...project,
entryFile: project?.metadata?.entryFile ?? null,
kind: project?.metadata?.kind ?? null,
},
active,
resolved,
),
);
}
case 'list_files': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
const params = new URLSearchParams();
if (typeof args.since === 'number' && Number.isFinite(args.since)) params.set('since', String(args.since));
const qs = params.toString();
const url = `${baseUrl}/api/projects/${encodeURIComponent(id)}/files${qs ? `?${qs}` : ''}`;
return ok(withActiveEcho(await getJson(url), active, resolved));
}
case 'get_file': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
let path = typeof args.path === 'string' ? args.path : '';
if (!path && active && active.fileName) {
path = active.fileName;
}
requireString(path, 'path');
const offset = typeof args.offset === 'number' && Number.isFinite(args.offset) ? Math.max(0, Math.floor(args.offset)) : 0;
const limit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? Math.max(1, Math.floor(args.limit)) : 2000;
return await getFile(baseUrl, id, path, active, resolved, offset, limit);
}
case 'get_artifact':
return await getArtifact(
baseUrl,
args.project,
args.entry,
args.include,
args.maxBytes,
);
case 'search_files': {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
requireString(args.query, 'query');
const params = new URLSearchParams({ q: String(args.query) });
if (args.pattern) params.set('pattern', String(args.pattern));
if (args.max) params.set('max', String(args.max));
return ok(
withActiveEcho(
await getJson(
`${baseUrl}/api/projects/${encodeURIComponent(id)}/search?${params.toString()}`,
),
active,
resolved,
),
);
}
case 'create_artifact':
return await createArtifact(baseUrl, args);
default:
return errorResult(`unknown tool: ${name}`);
}
} catch (err) {
return errorResult(formatError(err, baseUrl));
}
}
async function createArtifact(baseUrl: string, args: McpArgs) {
const { id, resolved, active } = await resolveProjectArg(baseUrl, args.project);
requireString(args.name, 'name');
requireString(args.content, 'content');
if (
args.artifactManifest !== undefined &&
(args.artifactManifest === null ||
typeof args.artifactManifest !== 'object' ||
Array.isArray(args.artifactManifest))
) {
throw new Error('artifactManifest must be an object');
}
const artifactManifest =
args.artifactManifest
? args.artifactManifest
: undefined;
const payload = await postCreateArtifactRequest({
baseUrl,
projectId: id,
input: {
name: args.name,
content: args.content,
encoding: args.encoding === 'base64' ? 'base64' : 'utf8',
...(artifactManifest === undefined ? {} : { artifactManifest }),
},
});
const result = payload && typeof payload === 'object' && !Array.isArray(payload)
? (payload as JsonObject)
: { result: payload };
return ok(withActiveEcho(result, active, resolved));
}
// Resource description renderers in some MCP UIs collapse whitespace
// poorly; keep our descriptions on a single line so they don't break
// the catalog list layout.
@ -533,6 +612,9 @@ async function resolveProjectId(baseUrl: string, arg: unknown): Promise<Resolved
.replace(/[\s_-]+/g, '-');
const target = norm(arg);
const idMatch = list.find((p) => p.id === arg);
if (idMatch) return { id: idMatch.id, name: idMatch.name, source: 'id' as const };
const exact = list.filter((p) => String(p.name || '').toLowerCase() === lower);
if (exact.length === 1) { const p = exact[0]!; return { id: p.id, name: p.name, source: 'exact' as const }; }
@ -942,4 +1024,4 @@ function errorMessage(err: unknown): string {
}
// Exported for unit tests only.
export { extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact, getFile };
export { extractRelativeRefs, resolveProjectId, resolveProjectArg, withActiveEcho, fetchProjectFile, getArtifact, getFile, createArtifact, handleMcpToolCall };

View file

@ -3,6 +3,7 @@ import {
defaultScenarioPluginIdForKind,
type PluginManifest,
} from '@open-design/contracts';
import { createProjectArtifactFile } from './artifact-create.js';
import { ArtifactRegressionError } from './artifact-stub-guard.js';
import { listDesignSystems } from './design-systems.js';
import {
@ -1013,7 +1014,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
const body = { file: meta };
return res.json(body);
}
const { name, content, encoding, artifactManifest } = req.body || {};
const { name, content, encoding, artifactManifest, artifact, overwrite } = req.body || {};
if (typeof name !== 'string' || typeof content !== 'string') {
return sendApiError(
res,
@ -1040,14 +1041,25 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
encoding === 'base64'
? Buffer.from(content, 'base64')
: Buffer.from(content, 'utf8');
const meta = await writeProjectFile(
PROJECTS_DIR,
req.params.id,
name,
buf,
{ artifactManifest },
uploadProject?.metadata,
);
const meta = artifact === true
? await createProjectArtifactFile({
projectsRoot: PROJECTS_DIR,
projectId: req.params.id,
input: { name, content, encoding, artifactManifest },
metadata: uploadProject?.metadata,
writeProjectFile,
})
: await writeProjectFile(
PROJECTS_DIR,
req.params.id,
name,
buf,
{
artifactManifest,
...(overwrite === false ? { overwrite: false } : {}),
},
uploadProject?.metadata,
);
/** @type {import('@open-design/contracts').ProjectFileResponse} */
const body = { file: meta };
res.json(body);
@ -1062,6 +1074,15 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
},
});
}
if (err?.code === 'EEXIST') {
return sendApiError(res, 409, 'FILE_EXISTS', 'file already exists');
}
if (err?.code === 'ARTIFACT_MANIFEST_REQUIRED') {
return sendApiError(res, 400, 'ARTIFACT_MANIFEST_REQUIRED', err.message);
}
if (err?.code === 'ARTIFACT_MANIFEST_INVALID') {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
}
},

View file

@ -63,7 +63,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
// Skip build/install dirs for linked folders so node_modules doesn't stall
// the walk on large repos.
const skipDirs = metadata?.baseDir ? SKIP_DIRS : undefined;
await collectFiles(dir, '', out, skipDirs);
await collectFiles(dir, '', out, skipDirs, dir);
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);
const since = Number(opts.since);
@ -100,7 +100,7 @@ export async function detectEntryFile(dir: string): Promise<string | null> {
return null;
}
async function collectFiles(dir, relDir, out, skipDirs?: Set<string>) {
async function collectFiles(dir, relDir, out, skipDirs?: Set<string>, projectRoot = dir) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
@ -114,13 +114,13 @@ async function collectFiles(dir, relDir, out, skipDirs?: Set<string>) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (skipDirs && skipDirs.has(e.name)) continue;
await collectFiles(full, rel, out, skipDirs);
await collectFiles(full, rel, out, skipDirs, projectRoot);
continue;
}
if (!e.isFile()) continue;
if (e.name.endsWith('.artifact.json')) continue;
const st = await stat(full);
const manifest = await readManifestForPath(dir, rel);
const manifest = await readManifestForPath(projectRoot, rel);
out.push({
name: rel,
path: rel,
@ -626,7 +626,9 @@ export async function writeProjectFile(
if (!overwrite) {
try {
await stat(target);
throw new Error('file already exists');
const err = new Error('file already exists');
err.code = 'EEXIST';
throw err;
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
}

View file

@ -0,0 +1,163 @@
import { describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { buildCreateArtifactRequestBody, createProjectArtifactFile } from '../src/artifact-create.js';
import { listFiles, writeProjectFile } from '../src/projects.js';
describe('normal artifact create helper', () => {
it('builds the non-overwrite HTTP request body used by MCP and CLI', () => {
expect(buildCreateArtifactRequestBody({
name: 'index.html',
content: '<!doctype html>',
})).toEqual({
name: 'index.html',
content: '<!doctype html>',
encoding: 'utf8',
artifact: true,
overwrite: false,
});
});
it('infers an artifact manifest and writes with overwrite disabled', async () => {
const writeProjectFile = vi.fn(async () => ({ name: 'deck.html' }));
await createProjectArtifactFile({
projectsRoot: '/tmp/projects',
projectId: 'project-1',
input: {
name: 'deck.html',
content: '<!doctype html><h1>Deck</h1>',
},
writeProjectFile,
});
expect(writeProjectFile).toHaveBeenCalledWith(
'/tmp/projects',
'project-1',
'deck.html',
Buffer.from('<!doctype html><h1>Deck</h1>', 'utf8'),
{
overwrite: false,
artifactManifest: expect.objectContaining({
kind: 'deck',
renderer: 'deck-html',
entry: 'deck.html',
}),
},
undefined,
);
});
it('passes existing target errors through to callers', async () => {
const err = new Error('file already exists') as Error & { code?: string };
err.code = 'EEXIST';
const writeProjectFile = vi.fn(async () => {
throw err;
});
await expect(createProjectArtifactFile({
projectsRoot: '/tmp/projects',
projectId: 'project-1',
input: {
name: 'index.html',
content: '<!doctype html>',
},
writeProjectFile,
})).rejects.toMatchObject({ code: 'EEXIST' });
});
it('rejects artifact creation when no manifest can be inferred', async () => {
const writeProjectFile = vi.fn(async () => ({ name: 'component.jsx' }));
await expect(createProjectArtifactFile({
projectsRoot: '/tmp/projects',
projectId: 'project-1',
input: {
name: 'component.jsx',
content: 'export function Component() { return <div />; }',
},
writeProjectFile,
})).rejects.toMatchObject({
code: 'ARTIFACT_MANIFEST_REQUIRED',
message: expect.stringContaining('artifactManifest is required'),
});
expect(writeProjectFile).not.toHaveBeenCalled();
});
it('treats null artifactManifest as missing when inference is unavailable', async () => {
const writeProjectFile = vi.fn(async () => ({ name: 'component.jsx' }));
await expect(createProjectArtifactFile({
projectsRoot: '/tmp/projects',
projectId: 'project-1',
input: {
name: 'component.jsx',
content: 'export function Component() { return <div />; }',
artifactManifest: null,
},
writeProjectFile,
})).rejects.toMatchObject({ code: 'ARTIFACT_MANIFEST_REQUIRED' });
expect(writeProjectFile).not.toHaveBeenCalled();
});
it('rejects invalid explicit manifests before writing the entry file', async () => {
const writeProjectFile = vi.fn(async () => ({ name: 'component.jsx' }));
await expect(createProjectArtifactFile({
projectsRoot: '/tmp/projects',
projectId: 'project-1',
input: {
name: 'component.jsx',
content: 'export function Component() { return <div />; }',
artifactManifest: {
kind: 'react-component',
exports: ['jsx'],
},
},
writeProjectFile,
})).rejects.toMatchObject({
code: 'ARTIFACT_MANIFEST_INVALID',
message: expect.stringContaining('artifactManifest.renderer must be a string'),
});
expect(writeProjectFile).not.toHaveBeenCalled();
});
it('lists explicit manifests for nested artifact entry files', async () => {
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-artifact-create-'));
try {
await createProjectArtifactFile({
projectsRoot,
projectId: 'project-1',
input: {
name: 'dry-run/deck.html',
content: '<!doctype html><h1>Deck</h1>',
artifactManifest: {
kind: 'deck',
renderer: 'deck-html',
exports: ['html', 'pdf'],
title: 'Nested Deck',
},
},
writeProjectFile: writeProjectFile as any,
});
const files = await listFiles(projectsRoot, 'project-1');
expect(files).toEqual([
expect.objectContaining({
name: 'dry-run/deck.html',
artifactKind: 'deck',
artifactManifest: expect.objectContaining({
kind: 'deck',
renderer: 'deck-html',
title: 'Nested Deck',
entry: 'dry-run/deck.html',
}),
}),
]);
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,188 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runArtifactsCli } from '../src/artifacts-cli.js';
const ORIGINAL_ENV = { ...process.env };
describe('od artifacts CLI', () => {
let stdoutWrite: { mockRestore: () => void };
let stderrWrite: { mockRestore: () => void };
let stdoutOutput: string[];
let stderrOutput: string[];
let fetchMock: ReturnType<typeof vi.fn>;
const tempRoots: string[] = [];
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
stdoutOutput = [];
stderrOutput = [];
stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
stdoutOutput.push(String(chunk));
return true;
});
stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
stderrOutput.push(String(chunk));
return true;
});
fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ file: { name: 'index.html' } }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
stdoutWrite.mockRestore();
stderrWrite.mockRestore();
process.env = ORIGINAL_ENV;
return Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))).then(() => undefined);
});
async function makeFile(name: string, contents: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'od-artifacts-cli-'));
tempRoots.push(root);
const filePath = path.join(root, name);
await writeFile(filePath, contents, 'utf8');
return filePath;
}
it('creates an artifact in an explicit project', async () => {
const inputPath = await makeFile('deck.html', '<!doctype html><h1>Deck</h1>');
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'deck.html' } }), { status: 200 });
});
const result = await runArtifactsCli([
'create',
'--daemon-url',
'http://127.0.0.1:17456',
'--project',
'project-1',
'--name',
'deck.html',
'--input',
inputPath,
]);
expect(result.exitCode).toBe(0);
const postCall = fetchMock.mock.calls.at(-1);
expect(postCall?.[0]).toBe('http://127.0.0.1:17456/api/projects/project-1/files');
expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({
name: 'deck.html',
content: '<!doctype html><h1>Deck</h1>',
encoding: 'utf8',
artifact: true,
overwrite: false,
});
expect(JSON.parse(stdoutOutput.join(''))).toEqual({ ok: true, file: { name: 'deck.html' } });
expect(stderrOutput.join('')).toBe('');
});
it('uses the active project when --project is omitted', async () => {
const inputPath = await makeFile('index.html', '<!doctype html><h1>Active</h1>');
fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/api/active')) {
return new Response(JSON.stringify({ active: true, projectId: 'active-1', projectName: 'Active', fileName: null }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'index.html' } }), { status: 200 });
});
const result = await runArtifactsCli([
'create',
'--daemon-url',
'http://127.0.0.1:17456',
'--name',
'index.html',
'--input',
inputPath,
]);
expect(result.exitCode).toBe(0);
expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:17456/api/projects/active-1/files');
expect(JSON.parse(stdoutOutput.join(''))).toMatchObject({
ok: true,
usedActiveContext: { projectId: 'active-1', projectName: 'Active' },
});
});
it('sends an explicit manifest file when provided', async () => {
const inputPath = await makeFile('report.md', '# Report');
const manifestPath = await makeFile('manifest.json', JSON.stringify({
kind: 'markdown-document',
renderer: 'markdown',
exports: ['md', 'html', 'pdf', 'zip'],
title: 'Report',
}));
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'report.md' } }), { status: 200 });
});
const result = await runArtifactsCli([
'create',
'--daemon-url',
'http://127.0.0.1:17456',
'--project',
'Demo',
'--name',
'report.md',
'--input',
inputPath,
'--manifest',
manifestPath,
]);
expect(result.exitCode).toBe(0);
expect(JSON.parse(String(fetchMock.mock.calls.at(-1)?.[1]?.body))).toMatchObject({
name: 'report.md',
content: '# Report',
artifactManifest: {
kind: 'markdown-document',
renderer: 'markdown',
exports: ['md', 'html', 'pdf', 'zip'],
title: 'Report',
},
});
});
it('surfaces existing-target rejection from the daemon', async () => {
const inputPath = await makeFile('index.html', '<!doctype html>');
fetchMock.mockImplementation(async (url: string) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ error: { code: 'FILE_EXISTS', message: 'file already exists' } }), { status: 409 });
});
const result = await runArtifactsCli([
'create',
'--daemon-url',
'http://127.0.0.1:17456',
'--project',
'Demo',
'--name',
'index.html',
'--input',
inputPath,
]);
expect(result.exitCode).toBe(1);
expect(stdoutOutput.join('')).toBe('');
expect(JSON.parse(stderrOutput.join(''))).toMatchObject({
ok: false,
status: 409,
error: { message: 'daemon artifact endpoint failed with 409' },
});
});
});

View file

@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { handleMcpToolCall } from '../src/mcp.js';
const originalFetch = globalThis.fetch;
function firstText(result: { content: Array<{ text: string }> }): string {
const item = result.content[0];
if (!item) throw new Error('expected MCP text content');
return item.text;
}
describe('public MCP create_artifact', () => {
afterEach(() => {
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('resolves project names and posts a non-overwrite artifact request', async () => {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(
JSON.stringify({
file: {
name: 'deck.html',
artifactManifest: { kind: 'deck', renderer: 'deck-html', entry: 'deck.html' },
},
}),
{ status: 200 },
);
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
project: 'Demo',
name: 'deck.html',
content: '<!doctype html><h1>Deck</h1>',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:17456/api/projects/project-1/files');
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
name: 'deck.html',
content: '<!doctype html><h1>Deck</h1>',
encoding: 'utf8',
artifact: true,
overwrite: false,
});
expect(JSON.parse(firstText(result))).toMatchObject({
file: {
name: 'deck.html',
artifactManifest: { kind: 'deck', renderer: 'deck-html', entry: 'deck.html' },
},
});
});
it('uses the active project when project is omitted', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/active')) {
return new Response(JSON.stringify({ active: true, projectId: 'active-1', projectName: 'Active', fileName: null }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'index.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
name: 'index.html',
content: '<!doctype html><h1>Active</h1>',
});
expect(fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:17456/api/projects/active-1/files');
expect(JSON.parse(firstText(result))).toMatchObject({
usedActiveContext: { projectId: 'active-1', projectName: 'Active' },
});
});
it('resolves non-UUID project ids and posts a non-overwrite artifact request', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'custom-project-id', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'index.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall('http://127.0.0.1:17457', 'create_artifact', {
project: 'custom-project-id',
name: 'index.html',
content: '<!doctype html>',
});
expect(fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:17457/api/projects/custom-project-id/files');
expect(JSON.parse(firstText(result))).toMatchObject({ file: { name: 'index.html' } });
});
it('rejects missing required fields before posting the artifact', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'unused.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
project: 'Demo',
content: '<!doctype html>',
});
expect(result).toMatchObject({ isError: true });
expect(firstText(result)).toContain('name is required');
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('/files'))).toBe(false);
});
it('rejects non-object artifact manifests before posting the artifact', async () => {
const fetchMock = vi.fn(async (url: string) => {
if (url.endsWith('/api/projects')) {
return new Response(JSON.stringify({ projects: [{ id: 'project-1', name: 'Demo' }] }), { status: 200 });
}
return new Response(JSON.stringify({ file: { name: 'unused.html' } }), { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
const result = await handleMcpToolCall('http://127.0.0.1:17456', 'create_artifact', {
project: 'Demo',
name: 'index.html',
content: '<!doctype html>',
artifactManifest: 'not-a-manifest',
});
expect(result).toMatchObject({ isError: true });
expect(firstText(result)).toContain('artifactManifest must be an object');
expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('/files'))).toBe(false);
});
});