mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add normal artifact creation via MCP and CLI (#2057)
This commit is contained in:
parent
0101a09b10
commit
7975514b3d
10 changed files with 1034 additions and 112 deletions
46
CONTEXT.md
Normal file
46
CONTEXT.md
Normal 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.
|
||||
119
apps/daemon/src/artifact-create.ts
Normal file
119
apps/daemon/src/artifact-create.ts
Normal 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;
|
||||
}
|
||||
152
apps/daemon/src/artifacts-cli.ts
Normal file
152
apps/daemon/src/artifacts-cli.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
163
apps/daemon/tests/artifact-create.test.ts
Normal file
163
apps/daemon/tests/artifact-create.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
188
apps/daemon/tests/artifacts-cli.test.ts
Normal file
188
apps/daemon/tests/artifacts-cli.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
137
apps/daemon/tests/mcp-create-artifact.test.ts
Normal file
137
apps/daemon/tests/mcp-create-artifact.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue