@ikenga/pkg-studio 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1535 @@
1
+ #!/usr/bin/env node
2
+ // src/index.ts
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ ErrorCode,
9
+ McpError
10
+ } from "@modelcontextprotocol/sdk/types.js";
11
+
12
+ // src/sidecar-client.ts
13
+ import { spawn } from "node:child_process";
14
+ import { createInterface } from "node:readline";
15
+ import { existsSync } from "node:fs";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, resolve as resolvePath } from "node:path";
18
+ function defaultSidecarPath() {
19
+ if (process.env.STUDIO_SIDECAR_PATH)
20
+ return process.env.STUDIO_SIDECAR_PATH;
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ return resolvePath(here, "../../sidecars/project/dist/sidecar.js");
23
+ }
24
+
25
+ class SidecarUnavailableError extends Error {
26
+ constructor(message) {
27
+ super(message);
28
+ this.name = "SidecarUnavailableError";
29
+ }
30
+ }
31
+
32
+ class SidecarRpcError extends Error {
33
+ code;
34
+ data;
35
+ constructor(code, message, data) {
36
+ super(message);
37
+ this.name = "SidecarRpcError";
38
+ this.code = code;
39
+ this.data = data;
40
+ }
41
+ }
42
+
43
+ class SidecarClient {
44
+ child = null;
45
+ rl = null;
46
+ nextId = 1;
47
+ pending = new Map;
48
+ listeners = new Set;
49
+ respawnAttempts = 0;
50
+ respawning = false;
51
+ sidecarPath;
52
+ env;
53
+ maxRespawnAttempts;
54
+ deadReason = null;
55
+ shuttingDown = false;
56
+ constructor(opts = {}) {
57
+ this.sidecarPath = opts.sidecarPath ?? defaultSidecarPath();
58
+ this.env = { ...process.env, ...opts.env ?? {} };
59
+ this.maxRespawnAttempts = opts.maxRespawnAttempts ?? 3;
60
+ }
61
+ start() {
62
+ if (this.child)
63
+ return;
64
+ this.spawnChild();
65
+ }
66
+ onEvent(fn) {
67
+ this.listeners.add(fn);
68
+ return () => this.listeners.delete(fn);
69
+ }
70
+ async call(method, params, opts = {}) {
71
+ if (this.deadReason && !this.child) {
72
+ throw new SidecarUnavailableError(this.deadReason);
73
+ }
74
+ if (!this.child)
75
+ this.spawnChild();
76
+ const child = this.child;
77
+ if (!child || !child.stdin || child.stdin.destroyed) {
78
+ throw new SidecarUnavailableError("sidecar stdin not writable");
79
+ }
80
+ const id = this.nextId++;
81
+ const req = { jsonrpc: "2.0", id, method, params };
82
+ const timeoutMs = opts.timeoutMs ?? 30000;
83
+ return new Promise((resolve, reject) => {
84
+ const timer = setTimeout(() => {
85
+ this.pending.delete(id);
86
+ reject(new Error(`sidecar call '${method}' timed out after ${timeoutMs}ms`));
87
+ }, timeoutMs);
88
+ this.pending.set(id, {
89
+ resolve: (v) => resolve(v),
90
+ reject,
91
+ timer
92
+ });
93
+ try {
94
+ child.stdin.write(JSON.stringify(req) + `
95
+ `);
96
+ } catch (e) {
97
+ clearTimeout(timer);
98
+ this.pending.delete(id);
99
+ reject(new SidecarUnavailableError(`sidecar write failed: ${e.message}`));
100
+ }
101
+ });
102
+ }
103
+ async stop() {
104
+ this.shuttingDown = true;
105
+ if (!this.child)
106
+ return;
107
+ try {
108
+ this.child.stdin?.end();
109
+ } catch {}
110
+ await new Promise((resolve) => {
111
+ const t = setTimeout(() => {
112
+ try {
113
+ this.child?.kill("SIGTERM");
114
+ } catch {}
115
+ resolve();
116
+ }, 1500);
117
+ this.child?.once("exit", () => {
118
+ clearTimeout(t);
119
+ resolve();
120
+ });
121
+ });
122
+ }
123
+ spawnChild() {
124
+ if (!existsSync(this.sidecarPath)) {
125
+ this.deadReason = `sidecar binary not found at ${this.sidecarPath}`;
126
+ process.stderr.write(`[studio-mcp] ${this.deadReason}
127
+ `);
128
+ return;
129
+ }
130
+ process.stderr.write(`[studio-mcp] spawning sidecar: node ${this.sidecarPath}
131
+ `);
132
+ const child = spawn(process.execPath, [this.sidecarPath], {
133
+ env: this.env,
134
+ stdio: ["pipe", "pipe", "pipe"]
135
+ });
136
+ this.child = child;
137
+ this.deadReason = null;
138
+ child.stderr?.on("data", (b) => {
139
+ process.stderr.write(b);
140
+ });
141
+ const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
142
+ this.rl = rl;
143
+ rl.on("line", (line) => {
144
+ const trimmed = line.trim();
145
+ if (!trimmed)
146
+ return;
147
+ let msg;
148
+ try {
149
+ msg = JSON.parse(trimmed);
150
+ } catch {
151
+ process.stderr.write(`[studio-mcp] sidecar emitted non-JSON line: ${trimmed.slice(0, 200)}
152
+ `);
153
+ return;
154
+ }
155
+ this.routeMessage(msg);
156
+ });
157
+ child.on("error", (err) => {
158
+ process.stderr.write(`[studio-mcp] sidecar spawn error: ${err.message}
159
+ `);
160
+ });
161
+ child.on("exit", (code, signal) => {
162
+ process.stderr.write(`[studio-mcp] sidecar exited code=${code} signal=${signal}
163
+ `);
164
+ const reason = `sidecar exited (code=${code} signal=${signal})`;
165
+ for (const [, p] of this.pending) {
166
+ clearTimeout(p.timer);
167
+ p.reject(new SidecarUnavailableError(reason));
168
+ }
169
+ this.pending.clear();
170
+ this.child = null;
171
+ this.rl?.close();
172
+ this.rl = null;
173
+ if (this.shuttingDown)
174
+ return;
175
+ this.attemptRespawn();
176
+ });
177
+ }
178
+ routeMessage(msg) {
179
+ if ("method" in msg && msg.method === "event" && "params" in msg) {
180
+ const params = msg.params;
181
+ for (const l of this.listeners) {
182
+ try {
183
+ l(params);
184
+ } catch (err) {
185
+ process.stderr.write(`[studio-mcp] event listener threw: ${err.message}
186
+ `);
187
+ }
188
+ }
189
+ return;
190
+ }
191
+ const resp = msg;
192
+ if (resp.id == null || typeof resp.id !== "number")
193
+ return;
194
+ const slot = this.pending.get(resp.id);
195
+ if (!slot)
196
+ return;
197
+ this.pending.delete(resp.id);
198
+ clearTimeout(slot.timer);
199
+ if (resp.error) {
200
+ slot.reject(new SidecarRpcError(resp.error.code, resp.error.message, resp.error.data));
201
+ } else {
202
+ slot.resolve(resp.result);
203
+ }
204
+ }
205
+ async attemptRespawn() {
206
+ if (this.respawning)
207
+ return;
208
+ this.respawning = true;
209
+ try {
210
+ while (this.respawnAttempts < this.maxRespawnAttempts) {
211
+ this.respawnAttempts++;
212
+ const backoff = Math.min(2000 * 2 ** (this.respawnAttempts - 1), 8000);
213
+ process.stderr.write(`[studio-mcp] sidecar respawn attempt ${this.respawnAttempts}/${this.maxRespawnAttempts} in ${backoff}ms
214
+ `);
215
+ await new Promise((r) => setTimeout(r, backoff));
216
+ if (this.shuttingDown)
217
+ return;
218
+ this.spawnChild();
219
+ if (this.child) {
220
+ this.respawnAttempts = 0;
221
+ return;
222
+ }
223
+ }
224
+ this.deadReason = `sidecar respawn gave up after ${this.maxRespawnAttempts} attempts`;
225
+ process.stderr.write(`[studio-mcp] ${this.deadReason}
226
+ `);
227
+ } finally {
228
+ this.respawning = false;
229
+ }
230
+ }
231
+ }
232
+
233
+ // src/catalog.ts
234
+ import { existsSync as existsSync2, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
235
+ import { dirname as dirname2, join, resolve as resolvePath2 } from "node:path";
236
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
237
+ import { createRequire } from "node:module";
238
+ function studioPkgRoot() {
239
+ const here = dirname2(fileURLToPath2(import.meta.url));
240
+ return resolvePath2(here, "..", "..");
241
+ }
242
+ function resolveArchetypesPkgSkills() {
243
+ try {
244
+ const req = createRequire(import.meta.url);
245
+ const manifest = req.resolve("@ikenga/studio-archetypes/manifest.json");
246
+ return join(dirname2(manifest), "skills");
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ function archetypeSkillRoots(pkgRoot = studioPkgRoot()) {
252
+ const roots = [];
253
+ const push = (dir) => {
254
+ if (dir && existsSync2(dir) && !roots.includes(dir))
255
+ roots.push(dir);
256
+ };
257
+ push(join(pkgRoot, "skills"));
258
+ push(resolveArchetypesPkgSkills());
259
+ push(resolvePath2(pkgRoot, "..", "..", "skills", "studio-archetypes", "skills"));
260
+ return roots;
261
+ }
262
+ function walkForFiles(root, filename, maxDepth = 6) {
263
+ const hits = [];
264
+ if (!existsSync2(root))
265
+ return hits;
266
+ function recurse(dir, depth) {
267
+ if (depth > maxDepth)
268
+ return;
269
+ let entries;
270
+ try {
271
+ entries = readdirSync(dir);
272
+ } catch {
273
+ return;
274
+ }
275
+ for (const e of entries) {
276
+ const full = join(dir, e);
277
+ let s;
278
+ try {
279
+ s = statSync(full);
280
+ } catch {
281
+ continue;
282
+ }
283
+ if (s.isDirectory()) {
284
+ recurse(full, depth + 1);
285
+ } else if (s.isFile() && e === filename) {
286
+ hits.push(full);
287
+ }
288
+ }
289
+ }
290
+ recurse(root, 0);
291
+ return hits;
292
+ }
293
+ function safeParseJson(path) {
294
+ try {
295
+ return JSON.parse(readFileSync(path, "utf8"));
296
+ } catch (e) {
297
+ process.stderr.write(`[studio-mcp][catalog] failed to parse ${path}: ${e.message}
298
+ `);
299
+ return null;
300
+ }
301
+ }
302
+ function loadBuiltinBlocks(pkgRoot = studioPkgRoot()) {
303
+ const entries = [];
304
+ const seen = new Set;
305
+ for (const skillsDir of archetypeSkillRoots(pkgRoot)) {
306
+ let skillNames;
307
+ try {
308
+ skillNames = readdirSync(skillsDir);
309
+ } catch {
310
+ continue;
311
+ }
312
+ for (const skill of skillNames) {
313
+ const blocksDir = join(skillsDir, skill, "blocks");
314
+ if (!existsSync2(blocksDir))
315
+ continue;
316
+ for (const file of walkForFiles(blocksDir, "block.json")) {
317
+ const body = safeParseJson(file);
318
+ if (!body)
319
+ continue;
320
+ const block_id = body.block_id ?? body.id;
321
+ if (!block_id || seen.has(block_id))
322
+ continue;
323
+ seen.add(block_id);
324
+ entries.push({
325
+ block_id,
326
+ kind: body.kind,
327
+ name: body.name,
328
+ tags: Array.isArray(body.tags) ? body.tags : undefined,
329
+ source: "builtin",
330
+ path: file,
331
+ body
332
+ });
333
+ }
334
+ }
335
+ }
336
+ return entries;
337
+ }
338
+ function loadBuiltinArchetypes(pkgRoot = studioPkgRoot()) {
339
+ const entries = [];
340
+ const seen = new Set;
341
+ for (const skillsDir of archetypeSkillRoots(pkgRoot)) {
342
+ let skillNames;
343
+ try {
344
+ skillNames = readdirSync(skillsDir);
345
+ } catch {
346
+ continue;
347
+ }
348
+ for (const skill of skillNames) {
349
+ if (!skill.startsWith("archetype-"))
350
+ continue;
351
+ const file = join(skillsDir, skill, "archetype.json");
352
+ if (!existsSync2(file))
353
+ continue;
354
+ const body = safeParseJson(file);
355
+ if (!body)
356
+ continue;
357
+ const archetype_id = body.archetype_id ?? body.id;
358
+ if (!archetype_id || seen.has(archetype_id))
359
+ continue;
360
+ seen.add(archetype_id);
361
+ entries.push({
362
+ archetype_id,
363
+ name: body.name,
364
+ source: "builtin",
365
+ path: file,
366
+ body
367
+ });
368
+ }
369
+ }
370
+ return entries;
371
+ }
372
+ function loadCustomBlocks(projectRoot) {
373
+ const customDir = join(projectRoot, "blocks", "custom");
374
+ const entries = [];
375
+ for (const file of walkForFiles(customDir, "block.json")) {
376
+ const body = safeParseJson(file);
377
+ if (!body)
378
+ continue;
379
+ const block_id = body.block_id ?? body.id;
380
+ if (!block_id)
381
+ continue;
382
+ entries.push({
383
+ block_id,
384
+ kind: body.kind,
385
+ name: body.name,
386
+ tags: Array.isArray(body.tags) ? body.tags : undefined,
387
+ source: "custom",
388
+ path: file,
389
+ body
390
+ });
391
+ }
392
+ return entries;
393
+ }
394
+ function loadCustomArchetypes(projectRoot) {
395
+ const customDir = join(projectRoot, "archetypes");
396
+ const entries = [];
397
+ if (!existsSync2(customDir))
398
+ return entries;
399
+ for (const file of walkForFiles(customDir, "archetype.json")) {
400
+ const body = safeParseJson(file);
401
+ if (!body)
402
+ continue;
403
+ const archetype_id = body.archetype_id ?? body.id;
404
+ if (!archetype_id)
405
+ continue;
406
+ entries.push({
407
+ archetype_id,
408
+ name: body.name,
409
+ source: "custom",
410
+ path: file,
411
+ body
412
+ });
413
+ }
414
+ return entries;
415
+ }
416
+
417
+ class Catalog {
418
+ builtinBlocks;
419
+ builtinArchetypes;
420
+ customBlocksByProject = new Map;
421
+ customArchetypesByProject = new Map;
422
+ constructor() {
423
+ this.builtinBlocks = loadBuiltinBlocks();
424
+ this.builtinArchetypes = loadBuiltinArchetypes();
425
+ }
426
+ refreshForProject(projectId, projectRoot) {
427
+ this.customBlocksByProject.set(projectId, loadCustomBlocks(projectRoot));
428
+ this.customArchetypesByProject.set(projectId, loadCustomArchetypes(projectRoot));
429
+ }
430
+ dropProject(projectId) {
431
+ this.customBlocksByProject.delete(projectId);
432
+ this.customArchetypesByProject.delete(projectId);
433
+ }
434
+ listBlocks(opts = {}) {
435
+ const merged = [
436
+ ...this.builtinBlocks,
437
+ ...opts.projectId ? this.customBlocksByProject.get(opts.projectId) ?? [] : []
438
+ ];
439
+ return merged.filter((b) => {
440
+ if (opts.kind && b.kind !== opts.kind)
441
+ return false;
442
+ if (opts.tags && opts.tags.length > 0) {
443
+ const have = new Set(b.tags ?? []);
444
+ if (!opts.tags.some((t) => have.has(t)))
445
+ return false;
446
+ }
447
+ return true;
448
+ });
449
+ }
450
+ getBlock(block_id, projectId) {
451
+ return this.listBlocks({ projectId }).find((b) => b.block_id === block_id);
452
+ }
453
+ listArchetypes(opts = {}) {
454
+ return [
455
+ ...this.builtinArchetypes,
456
+ ...opts.projectId ? this.customArchetypesByProject.get(opts.projectId) ?? [] : []
457
+ ];
458
+ }
459
+ getArchetype(archetype_id, projectId) {
460
+ return this.listArchetypes({ projectId }).find((a) => a.archetype_id === archetype_id);
461
+ }
462
+ writeCustomBlock(projectRoot, projectId, block) {
463
+ const block_id = block.block_id ?? block.id;
464
+ if (!block_id)
465
+ throw new Error("block.block_id is required");
466
+ const dir = join(projectRoot, "blocks", "custom", block_id);
467
+ mkdirSync(dir, { recursive: true });
468
+ const file = join(dir, "block.json");
469
+ writeFileSync(file, JSON.stringify(block, null, 2) + `
470
+ `, "utf8");
471
+ this.refreshForProject(projectId, projectRoot);
472
+ const found = this.getBlock(block_id, projectId);
473
+ if (!found)
474
+ throw new Error(`failed to register block ${block_id} after write`);
475
+ return found;
476
+ }
477
+ deleteCustomBlock(projectRoot, projectId, block_id) {
478
+ const entry = this.getBlock(block_id, projectId);
479
+ if (!entry)
480
+ return { ok: false, reason: "not-found" };
481
+ if (entry.source === "builtin")
482
+ return { ok: false, reason: "cannot-delete-builtin" };
483
+ try {
484
+ const dir = dirname2(entry.path);
485
+ rmSync(dir, { recursive: true, force: true });
486
+ } catch (e) {
487
+ return { ok: false, reason: e.message };
488
+ }
489
+ this.refreshForProject(projectId, projectRoot);
490
+ return { ok: true };
491
+ }
492
+ writeCustomArchetype(projectRoot, projectId, archetype) {
493
+ const archetype_id = archetype.archetype_id ?? archetype.id;
494
+ if (!archetype_id)
495
+ throw new Error("archetype.archetype_id is required");
496
+ const dir = join(projectRoot, "archetypes", archetype_id);
497
+ mkdirSync(dir, { recursive: true });
498
+ const file = join(dir, "archetype.json");
499
+ writeFileSync(file, JSON.stringify(archetype, null, 2) + `
500
+ `, "utf8");
501
+ this.refreshForProject(projectId, projectRoot);
502
+ const found = this.getArchetype(archetype_id, projectId);
503
+ if (!found)
504
+ throw new Error(`failed to register archetype ${archetype_id} after write`);
505
+ return found;
506
+ }
507
+ deleteCustomArchetype(projectRoot, projectId, archetype_id) {
508
+ const entry = this.getArchetype(archetype_id, projectId);
509
+ if (!entry)
510
+ return { ok: false, reason: "not-found" };
511
+ if (entry.source === "builtin")
512
+ return { ok: false, reason: "cannot-delete-builtin" };
513
+ try {
514
+ const dir = dirname2(entry.path);
515
+ rmSync(dir, { recursive: true, force: true });
516
+ } catch (e) {
517
+ return { ok: false, reason: e.message };
518
+ }
519
+ this.refreshForProject(projectId, projectRoot);
520
+ return { ok: true };
521
+ }
522
+ }
523
+
524
+ // src/tools/project.ts
525
+ async function callSidecar(sidecar, method, params) {
526
+ try {
527
+ const r = await sidecar.call(method, params);
528
+ return r;
529
+ } catch (e) {
530
+ if (e instanceof SidecarUnavailableError) {
531
+ return { ok: false, error: "sidecar-unavailable", message: e.message };
532
+ }
533
+ if (e instanceof SidecarRpcError) {
534
+ return { ok: false, error: "sidecar-rpc-error", message: `${e.code}: ${e.message}` };
535
+ }
536
+ return { ok: false, error: "internal-error", message: e.message };
537
+ }
538
+ }
539
+ function projectTools(sidecar, registry) {
540
+ return [
541
+ {
542
+ name: "project.open",
543
+ description: "Open a project on disk. Returns projectId + parsed Project.",
544
+ inputSchema: {
545
+ type: "object",
546
+ properties: {
547
+ path: { type: "string", description: "Absolute or cwd-relative project root path." }
548
+ },
549
+ required: ["path"],
550
+ additionalProperties: false
551
+ },
552
+ async handler(args) {
553
+ const r = await callSidecar(sidecar, "project.open", { path: args.path });
554
+ if (r.ok) {
555
+ const rec = r;
556
+ if (typeof rec.projectId === "string") {
557
+ registry.set(rec.projectId, {
558
+ path: args.path,
559
+ project: rec.project
560
+ });
561
+ }
562
+ }
563
+ return r;
564
+ }
565
+ },
566
+ {
567
+ name: "project.close",
568
+ description: "Close an open project; releases its FS watcher + LRU entries.",
569
+ inputSchema: {
570
+ type: "object",
571
+ properties: { projectId: { type: "string" } },
572
+ required: ["projectId"],
573
+ additionalProperties: false
574
+ },
575
+ async handler(args) {
576
+ const r = await callSidecar(sidecar, "project.close", { projectId: args.projectId });
577
+ if (r.ok)
578
+ registry.delete(args.projectId);
579
+ return r;
580
+ }
581
+ },
582
+ {
583
+ name: "project.list",
584
+ description: "List previously-opened projects (most recent first).",
585
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
586
+ async handler() {
587
+ return callSidecar(sidecar, "project.list", undefined);
588
+ }
589
+ },
590
+ {
591
+ name: "project.create",
592
+ description: "Scaffold a new project on disk from an archetype id, then open it.",
593
+ inputSchema: {
594
+ type: "object",
595
+ properties: {
596
+ archetype_id: { type: "string" },
597
+ path: { type: "string", description: "Project root directory to create." },
598
+ name: { type: "string", description: "Human-readable project title." }
599
+ },
600
+ required: ["archetype_id", "path", "name"],
601
+ additionalProperties: false
602
+ },
603
+ async handler(args) {
604
+ const r = await callSidecar(sidecar, "project.create", {
605
+ archetype_id: args.archetype_id,
606
+ path: args.path,
607
+ name: args.name
608
+ });
609
+ if (r.ok) {
610
+ const rec = r;
611
+ if (typeof rec.projectId === "string") {
612
+ registry.set(rec.projectId, {
613
+ path: args.path,
614
+ project: rec.project
615
+ });
616
+ }
617
+ }
618
+ return r;
619
+ }
620
+ },
621
+ {
622
+ name: "project.info",
623
+ description: "Fetch live project info: parsed Project + LRU openCells + queueDepth.",
624
+ inputSchema: {
625
+ type: "object",
626
+ properties: { projectId: { type: "string" } },
627
+ required: ["projectId"],
628
+ additionalProperties: false
629
+ },
630
+ async handler(args) {
631
+ return callSidecar(sidecar, "project.info", { projectId: args.projectId });
632
+ }
633
+ }
634
+ ];
635
+ }
636
+
637
+ // src/tools/storyboard.ts
638
+ function storyboardTools(sidecar) {
639
+ return [
640
+ {
641
+ name: "storyboard.read",
642
+ description: "Read the full storyboard for an open project. Returns the parsed Project + cell index.",
643
+ inputSchema: {
644
+ type: "object",
645
+ properties: { projectId: { type: "string" } },
646
+ required: ["projectId"],
647
+ additionalProperties: false
648
+ },
649
+ handler: (args) => callSidecar(sidecar, "storyboard.read", { projectId: args.projectId })
650
+ },
651
+ {
652
+ name: "storyboard.read_cell",
653
+ description: "Read a single cell by id.",
654
+ inputSchema: {
655
+ type: "object",
656
+ properties: {
657
+ projectId: { type: "string" },
658
+ cellId: { type: "string" }
659
+ },
660
+ required: ["projectId", "cellId"],
661
+ additionalProperties: false
662
+ },
663
+ handler: (args) => callSidecar(sidecar, "storyboard.read_cell", { projectId: args.projectId, cellId: args.cellId })
664
+ },
665
+ {
666
+ name: "storyboard.read_fountain",
667
+ description: "Read the project's Fountain screenplay source at <root>/script.fountain. Returns { exists, text }. `exists:false` (empty text) means the project has no script.fountain on disk yet — not an error.",
668
+ inputSchema: {
669
+ type: "object",
670
+ properties: { projectId: { type: "string" } },
671
+ required: ["projectId"],
672
+ additionalProperties: false
673
+ },
674
+ handler: (args) => callSidecar(sidecar, "storyboard.read_fountain", { projectId: args.projectId })
675
+ },
676
+ {
677
+ name: "storyboard.read_cell_content",
678
+ description: "Read a cell's authored source file (the markup at its content_path). Returns { html, content_path, exists }. `exists:false` (empty html) means the cell has no source written yet.",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ projectId: { type: "string" },
683
+ cellId: { type: "string" }
684
+ },
685
+ required: ["projectId", "cellId"],
686
+ additionalProperties: false
687
+ },
688
+ handler: (args) => callSidecar(sidecar, "storyboard.read_cell_content", {
689
+ projectId: args.projectId,
690
+ cellId: args.cellId
691
+ })
692
+ },
693
+ {
694
+ name: "storyboard.write_cell_content",
695
+ description: "Persist a cell's authored source file (the FULL edited html) to its content_path and bump last_edited (the project FS watcher surfaces the change). This is the durable save seam for the cell editor.",
696
+ inputSchema: {
697
+ type: "object",
698
+ properties: {
699
+ projectId: { type: "string" },
700
+ cellId: { type: "string" },
701
+ html: { type: "string" }
702
+ },
703
+ required: ["projectId", "cellId", "html"],
704
+ additionalProperties: false
705
+ },
706
+ handler: (args) => callSidecar(sidecar, "storyboard.write_cell_content", {
707
+ projectId: args.projectId,
708
+ cellId: args.cellId,
709
+ html: args.html
710
+ })
711
+ },
712
+ {
713
+ name: "storyboard.write_cell",
714
+ description: "Overwrite a cell. Emits cells/changed on success.",
715
+ inputSchema: {
716
+ type: "object",
717
+ properties: {
718
+ projectId: { type: "string" },
719
+ cell: { type: "object", additionalProperties: true }
720
+ },
721
+ required: ["projectId", "cell"],
722
+ additionalProperties: false
723
+ },
724
+ handler: (args) => callSidecar(sidecar, "storyboard.write_cell", { projectId: args.projectId, cell: args.cell })
725
+ },
726
+ {
727
+ name: "storyboard.create_cell",
728
+ description: "Create a new cell. Emits cells/changed on success.",
729
+ inputSchema: {
730
+ type: "object",
731
+ properties: {
732
+ projectId: { type: "string" },
733
+ cell: { type: "object", additionalProperties: true }
734
+ },
735
+ required: ["projectId", "cell"],
736
+ additionalProperties: false
737
+ },
738
+ handler: (args) => callSidecar(sidecar, "storyboard.create_cell", { projectId: args.projectId, cell: args.cell })
739
+ },
740
+ {
741
+ name: "storyboard.delete_cell",
742
+ description: "Delete a cell by id.",
743
+ inputSchema: {
744
+ type: "object",
745
+ properties: {
746
+ projectId: { type: "string" },
747
+ cellId: { type: "string" }
748
+ },
749
+ required: ["projectId", "cellId"],
750
+ additionalProperties: false
751
+ },
752
+ handler: (args) => callSidecar(sidecar, "storyboard.delete_cell", { projectId: args.projectId, cellId: args.cellId })
753
+ },
754
+ {
755
+ name: "storyboard.list_cells",
756
+ description: "List cells in an open project. Optional beat_id + rung filters.",
757
+ inputSchema: {
758
+ type: "object",
759
+ properties: {
760
+ projectId: { type: "string" },
761
+ beat_id: { type: "string" },
762
+ rung: { type: "string", enum: ["0_beat_sheet", "1_lofi", "2_hifi"] }
763
+ },
764
+ required: ["projectId"],
765
+ additionalProperties: false
766
+ },
767
+ handler: (args) => callSidecar(sidecar, "storyboard.list_cells", {
768
+ projectId: args.projectId,
769
+ beat_id: args.beat_id,
770
+ rung: args.rung
771
+ })
772
+ },
773
+ {
774
+ name: "storyboard.upsert_beat",
775
+ description: "Insert or update a beat (script-level) on the project.",
776
+ inputSchema: {
777
+ type: "object",
778
+ properties: {
779
+ projectId: { type: "string" },
780
+ beat: { type: "object", additionalProperties: true }
781
+ },
782
+ required: ["projectId", "beat"],
783
+ additionalProperties: false
784
+ },
785
+ handler: (args) => callSidecar(sidecar, "storyboard.upsert_beat", { projectId: args.projectId, beat: args.beat })
786
+ },
787
+ {
788
+ name: "storyboard.upsert_rung",
789
+ description: "Insert or update a per-rung block (beatsheet/lofi/hifi) on a cell. Defaults to the cell's own rung when rungKey is omitted.",
790
+ inputSchema: {
791
+ type: "object",
792
+ properties: {
793
+ projectId: { type: "string" },
794
+ cellId: { type: "string" },
795
+ rung: { type: "object", additionalProperties: true },
796
+ rungKey: { type: "string", enum: ["0_beat_sheet", "1_lofi", "2_hifi"] }
797
+ },
798
+ required: ["projectId", "cellId", "rung"],
799
+ additionalProperties: false
800
+ },
801
+ handler: (args) => callSidecar(sidecar, "storyboard.upsert_rung", {
802
+ projectId: args.projectId,
803
+ cellId: args.cellId,
804
+ rung: args.rung,
805
+ rungKey: args.rungKey
806
+ })
807
+ },
808
+ {
809
+ name: "storyboard.set_approved",
810
+ description: "Flip the approved boolean on a cell.",
811
+ inputSchema: {
812
+ type: "object",
813
+ properties: {
814
+ projectId: { type: "string" },
815
+ cellId: { type: "string" },
816
+ approved: { type: "boolean" }
817
+ },
818
+ required: ["projectId", "cellId", "approved"],
819
+ additionalProperties: false
820
+ },
821
+ handler: (args) => callSidecar(sidecar, "storyboard.set_approved", {
822
+ projectId: args.projectId,
823
+ cellId: args.cellId,
824
+ approved: args.approved
825
+ })
826
+ }
827
+ ];
828
+ }
829
+
830
+ // src/tools/anchor.ts
831
+ function anchorTools(sidecar) {
832
+ return [
833
+ {
834
+ name: "anchor.list",
835
+ description: "List all anchors in the project.",
836
+ inputSchema: {
837
+ type: "object",
838
+ properties: { projectId: { type: "string" } },
839
+ required: ["projectId"],
840
+ additionalProperties: false
841
+ },
842
+ handler: (args) => callSidecar(sidecar, "anchor.list", { projectId: args.projectId })
843
+ },
844
+ {
845
+ name: "anchor.create",
846
+ description: "Create a new anchor (project-scoped reusable asset reference).",
847
+ inputSchema: {
848
+ type: "object",
849
+ properties: {
850
+ projectId: { type: "string" },
851
+ anchor: { type: "object", additionalProperties: true }
852
+ },
853
+ required: ["projectId", "anchor"],
854
+ additionalProperties: false
855
+ },
856
+ handler: (args) => callSidecar(sidecar, "anchor.create", { projectId: args.projectId, anchor: args.anchor })
857
+ },
858
+ {
859
+ name: "anchor.delete",
860
+ description: "Delete an anchor by id.",
861
+ inputSchema: {
862
+ type: "object",
863
+ properties: {
864
+ projectId: { type: "string" },
865
+ anchorId: { type: "string" }
866
+ },
867
+ required: ["projectId", "anchorId"],
868
+ additionalProperties: false
869
+ },
870
+ handler: (args) => callSidecar(sidecar, "anchor.delete", { projectId: args.projectId, anchorId: args.anchorId })
871
+ }
872
+ ];
873
+ }
874
+
875
+ // src/tools/asset.ts
876
+ function assetTools(sidecar) {
877
+ return [
878
+ {
879
+ name: "asset.list",
880
+ description: "List assets in a project. Optional `kind` filter (image, video, audio, font).",
881
+ inputSchema: {
882
+ type: "object",
883
+ properties: {
884
+ projectId: { type: "string" },
885
+ kind: { type: "string" }
886
+ },
887
+ required: ["projectId"],
888
+ additionalProperties: false
889
+ },
890
+ handler: (args) => callSidecar(sidecar, "asset.list", { projectId: args.projectId, kind: args.kind })
891
+ },
892
+ {
893
+ name: "asset.import",
894
+ description: "Import an asset from a URL or local path into the project assets dir.",
895
+ inputSchema: {
896
+ type: "object",
897
+ properties: {
898
+ projectId: { type: "string" },
899
+ source: { type: "string", description: "URL or absolute path of the source asset." },
900
+ kind: { type: "string" }
901
+ },
902
+ required: ["projectId", "source"],
903
+ additionalProperties: false
904
+ },
905
+ handler: (args) => callSidecar(sidecar, "asset.import", {
906
+ projectId: args.projectId,
907
+ source: args.source,
908
+ kind: args.kind
909
+ })
910
+ },
911
+ {
912
+ name: "asset.resolve",
913
+ description: "Resolve an AssetRef (project-relative asset id) to an absolute on-disk path + metadata.",
914
+ inputSchema: {
915
+ type: "object",
916
+ properties: {
917
+ projectId: { type: "string" },
918
+ assetId: { type: "string" }
919
+ },
920
+ required: ["projectId", "assetId"],
921
+ additionalProperties: false
922
+ },
923
+ handler: (args) => callSidecar(sidecar, "asset.resolve", { projectId: args.projectId, assetId: args.assetId })
924
+ }
925
+ ];
926
+ }
927
+
928
+ // src/tools/composition.ts
929
+ function compositionTools(sidecar, _registry) {
930
+ return [
931
+ {
932
+ name: "composition.render",
933
+ description: "Enqueue a render for a cell. The sidecar resolves the engine via G23 (auto by content_path extension) and validates against G2 capabilities BEFORE enqueueing. Returns { ok:true, recordId, engine } or a structured failure.",
934
+ inputSchema: {
935
+ type: "object",
936
+ properties: {
937
+ projectId: { type: "string" },
938
+ cellId: { type: "string" },
939
+ engine: { type: "string", description: 'Optional. "auto" or undefined triggers G23 resolution.' },
940
+ variant: { type: "string" },
941
+ range: {
942
+ type: "object",
943
+ properties: { start_ms: { type: "number" }, end_ms: { type: "number" } },
944
+ additionalProperties: false
945
+ },
946
+ aspect_ratio: { type: "string", enum: ["16:9", "9:16", "1:1"] },
947
+ resolution: {
948
+ type: "object",
949
+ properties: { w: { type: "number" }, h: { type: "number" } },
950
+ required: ["w", "h"],
951
+ additionalProperties: false
952
+ }
953
+ },
954
+ required: ["projectId", "cellId"],
955
+ additionalProperties: false
956
+ },
957
+ handler: (args) => callSidecar(sidecar, "render.enqueue", {
958
+ projectId: args.projectId,
959
+ cellId: args.cellId,
960
+ engine: args.engine,
961
+ variant: args.variant,
962
+ range: args.range,
963
+ aspect_ratio: args.aspect_ratio,
964
+ resolution: args.resolution
965
+ })
966
+ },
967
+ {
968
+ name: "composition.preview",
969
+ description: "Generate a preview frame URL for a cell. Sidecar resolves the engine (G23) and calls adapter.preview.",
970
+ inputSchema: {
971
+ type: "object",
972
+ properties: {
973
+ projectId: { type: "string" },
974
+ cellId: { type: "string" },
975
+ engine: { type: "string" }
976
+ },
977
+ required: ["projectId", "cellId"],
978
+ additionalProperties: false
979
+ },
980
+ handler: (args) => callSidecar(sidecar, "composition.preview", {
981
+ projectId: args.projectId,
982
+ cellId: args.cellId,
983
+ engine: args.engine
984
+ })
985
+ },
986
+ {
987
+ name: "composition.validate",
988
+ description: "Run the resolved engine's `validate` against a cell. Returns Diagnostic[] under `diagnostics`.",
989
+ inputSchema: {
990
+ type: "object",
991
+ properties: {
992
+ projectId: { type: "string" },
993
+ cellId: { type: "string" },
994
+ engine: { type: "string" }
995
+ },
996
+ required: ["projectId", "cellId"],
997
+ additionalProperties: false
998
+ },
999
+ handler: (args) => callSidecar(sidecar, "composition.validate", {
1000
+ projectId: args.projectId,
1001
+ cellId: args.cellId,
1002
+ engine: args.engine
1003
+ })
1004
+ }
1005
+ ];
1006
+ }
1007
+
1008
+ // src/tools/render.ts
1009
+ function renderTools(sidecar) {
1010
+ return [
1011
+ {
1012
+ name: "render.list_engines",
1013
+ description: "Return the G2 capability matrix for every renderer adapter known to the sidecar registry. No open project required.",
1014
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1015
+ handler: () => callSidecar(sidecar, "render.list_engines", {})
1016
+ },
1017
+ {
1018
+ name: "render.status",
1019
+ description: "Look up a render record by id from the sidecar queue.",
1020
+ inputSchema: {
1021
+ type: "object",
1022
+ properties: { recordId: { type: "string" } },
1023
+ required: ["recordId"],
1024
+ additionalProperties: false
1025
+ },
1026
+ handler: (args) => callSidecar(sidecar, "render.status", { recordId: args.recordId })
1027
+ },
1028
+ {
1029
+ name: "render.cancel",
1030
+ description: "Cancel a queued or running render (aborts the in-flight adapter or marks a queued row cancelled).",
1031
+ inputSchema: {
1032
+ type: "object",
1033
+ properties: { recordId: { type: "string" } },
1034
+ required: ["recordId"],
1035
+ additionalProperties: false
1036
+ },
1037
+ handler: (args) => callSidecar(sidecar, "render.cancel", { recordId: args.recordId })
1038
+ },
1039
+ {
1040
+ name: "render.list",
1041
+ description: "List render records from the sidecar queue. Optional projectId + status filters.",
1042
+ inputSchema: {
1043
+ type: "object",
1044
+ properties: {
1045
+ projectId: { type: "string" },
1046
+ status: { type: "string", enum: ["queued", "running", "done", "failed", "cancelled"] }
1047
+ },
1048
+ additionalProperties: false
1049
+ },
1050
+ handler: (args) => callSidecar(sidecar, "render.list", { projectId: args.projectId, status: args.status })
1051
+ },
1052
+ {
1053
+ name: "render.read_bytes",
1054
+ description: "Read a finished render's MP4 off disk, base64-encoded, so a UI can preview it as a blob: URL in-pane (file:// is unloadable from a sandboxed srcdoc pane). Returns { ok:true, base64, mime, sizeBytes, path }. Keyed on the render record id.",
1055
+ inputSchema: {
1056
+ type: "object",
1057
+ properties: { recordId: { type: "string" } },
1058
+ required: ["recordId"],
1059
+ additionalProperties: false
1060
+ },
1061
+ handler: (args) => callSidecar(sidecar, "render.read_bytes", { recordId: args.recordId })
1062
+ }
1063
+ ];
1064
+ }
1065
+
1066
+ // src/tools/export.ts
1067
+ function exportTools(sidecar) {
1068
+ return [
1069
+ {
1070
+ name: "export.compose",
1071
+ description: "Compose a project's rendered cells into a single deliverable MP4 (concat + transitions + narration/music mix). Selection defaults to all cells in beat order; pass `rung` to restrict to one rung or `cellIds` for an explicit ordered subset. Returns { ok:true, exportId, outputPath, reveal:true }. The export runs serially in the sidecar — poll export.status for completion.",
1072
+ inputSchema: {
1073
+ type: "object",
1074
+ properties: {
1075
+ projectId: { type: "string" },
1076
+ rung: { type: "number", description: "Optional. 0|1|2 — restrict to one rung." },
1077
+ cellIds: {
1078
+ type: "array",
1079
+ items: { type: "string" },
1080
+ description: "Optional. Explicit cell uids, in export order (wins over rung)."
1081
+ },
1082
+ music_preset: {
1083
+ type: "string",
1084
+ enum: ["none", "silent", "ambient", "upbeat"],
1085
+ description: "Music bed. none/silent ship now; ambient/upbeat fall back to silent until bed asset files exist (assets/music/<preset>.mp3)."
1086
+ },
1087
+ outputPath: {
1088
+ type: "string",
1089
+ description: "Optional. Absolute or project-relative output path; defaults to exports/<ISO-8601>.mp4."
1090
+ },
1091
+ engine: {
1092
+ type: "string",
1093
+ description: "Optional. Preferred render-engine subfolder to pull cell MP4s from."
1094
+ }
1095
+ },
1096
+ required: ["projectId"],
1097
+ additionalProperties: false
1098
+ },
1099
+ handler: (args) => callSidecar(sidecar, "export.compose", {
1100
+ projectId: args.projectId,
1101
+ rung: args.rung,
1102
+ cellIds: args.cellIds,
1103
+ music_preset: args.music_preset,
1104
+ outputPath: args.outputPath,
1105
+ engine: args.engine
1106
+ })
1107
+ },
1108
+ {
1109
+ name: "export.status",
1110
+ description: "Fetch the status of a single export by id. Returns { ok:true, record: { exportId, projectId, status, outputPath?, error? } }.",
1111
+ inputSchema: {
1112
+ type: "object",
1113
+ properties: { exportId: { type: "string" } },
1114
+ required: ["exportId"],
1115
+ additionalProperties: false
1116
+ },
1117
+ handler: (args) => callSidecar(sidecar, "export.status", { exportId: args.exportId })
1118
+ },
1119
+ {
1120
+ name: "export.list",
1121
+ description: "List export records (most recent first). Optionally scope to one project. Returns { ok:true, exports: [...] }.",
1122
+ inputSchema: {
1123
+ type: "object",
1124
+ properties: { projectId: { type: "string" } },
1125
+ additionalProperties: false
1126
+ },
1127
+ handler: (args) => callSidecar(sidecar, "export.list", { projectId: args.projectId })
1128
+ },
1129
+ {
1130
+ name: "export.read_bytes",
1131
+ description: "Read a finished export's MP4 off disk, base64-encoded, for in-pane composed playback as a blob: URL. Returns { ok:true, base64, mime, sizeBytes, path }. Keyed on the export id.",
1132
+ inputSchema: {
1133
+ type: "object",
1134
+ properties: { exportId: { type: "string" } },
1135
+ required: ["exportId"],
1136
+ additionalProperties: false
1137
+ },
1138
+ handler: (args) => callSidecar(sidecar, "export.read_bytes", { exportId: args.exportId })
1139
+ },
1140
+ {
1141
+ name: "export.check_bed",
1142
+ description: "Pre-flight audio-bed check: does a real music-bed file exist on disk for the chosen preset? Returns { ok:true, hasBed, willBeSilent, byDesign, path? }. none/silent are silent by design; ambient/upbeat need assets/music/<preset>.mp3.",
1143
+ inputSchema: {
1144
+ type: "object",
1145
+ properties: {
1146
+ projectId: { type: "string" },
1147
+ music_preset: { type: "string", enum: ["none", "silent", "ambient", "upbeat"] }
1148
+ },
1149
+ required: ["projectId"],
1150
+ additionalProperties: false
1151
+ },
1152
+ handler: (args) => callSidecar(sidecar, "export.check_bed", {
1153
+ projectId: args.projectId,
1154
+ music_preset: args.music_preset
1155
+ })
1156
+ }
1157
+ ];
1158
+ }
1159
+
1160
+ // src/tools/block.ts
1161
+ function blockTools(catalog, registry) {
1162
+ return [
1163
+ {
1164
+ name: "block.list",
1165
+ description: "List blocks in the catalog. Filters: kind, tags (OR-match). Project-scoped custom blocks merge in when projectId is supplied.",
1166
+ inputSchema: {
1167
+ type: "object",
1168
+ properties: {
1169
+ projectId: { type: "string" },
1170
+ kind: { type: "string" },
1171
+ tags: { type: "array", items: { type: "string" } }
1172
+ },
1173
+ additionalProperties: false
1174
+ },
1175
+ async handler(args) {
1176
+ const projectId = args.projectId;
1177
+ if (projectId) {
1178
+ const open = registry.get(projectId);
1179
+ if (open)
1180
+ catalog.refreshForProject(projectId, open.path);
1181
+ }
1182
+ const blocks = catalog.listBlocks({
1183
+ projectId,
1184
+ kind: args.kind,
1185
+ tags: args.tags
1186
+ });
1187
+ return {
1188
+ ok: true,
1189
+ blocks: blocks.map((b) => ({
1190
+ block_id: b.block_id,
1191
+ kind: b.kind,
1192
+ name: b.name,
1193
+ tags: b.tags ?? [],
1194
+ source: b.source
1195
+ }))
1196
+ };
1197
+ }
1198
+ },
1199
+ {
1200
+ name: "block.get",
1201
+ description: "Fetch the full block.json body by id.",
1202
+ inputSchema: {
1203
+ type: "object",
1204
+ properties: {
1205
+ block_id: { type: "string" },
1206
+ projectId: { type: "string" }
1207
+ },
1208
+ required: ["block_id"],
1209
+ additionalProperties: false
1210
+ },
1211
+ async handler(args) {
1212
+ const projectId = args.projectId;
1213
+ if (projectId) {
1214
+ const open = registry.get(projectId);
1215
+ if (open)
1216
+ catalog.refreshForProject(projectId, open.path);
1217
+ }
1218
+ const b = catalog.getBlock(args.block_id, projectId);
1219
+ if (!b)
1220
+ return { ok: false, error: "block-not-found", message: args.block_id };
1221
+ return { ok: true, block: b.body, source: b.source };
1222
+ }
1223
+ },
1224
+ {
1225
+ name: "block.instantiate",
1226
+ description: "Instantiate a block into a concrete cell JSON given bindings. Does NOT write to disk — pipe the result into storyboard.write_cell.",
1227
+ inputSchema: {
1228
+ type: "object",
1229
+ properties: {
1230
+ block_id: { type: "string" },
1231
+ bindings: { type: "object", additionalProperties: true },
1232
+ projectId: { type: "string" }
1233
+ },
1234
+ required: ["block_id", "bindings"],
1235
+ additionalProperties: false
1236
+ },
1237
+ async handler(args) {
1238
+ const projectId = args.projectId;
1239
+ const b = catalog.getBlock(args.block_id, projectId);
1240
+ if (!b)
1241
+ return { ok: false, error: "block-not-found", message: args.block_id };
1242
+ const template = b.body.template ?? {};
1243
+ const merged = { ...template, ...args.bindings ?? {}, block_id: b.block_id };
1244
+ return { ok: true, cell: merged };
1245
+ }
1246
+ },
1247
+ {
1248
+ name: "block.create_custom",
1249
+ description: "Write a custom block under the project blocks/custom/ dir. Requires an open project.",
1250
+ inputSchema: {
1251
+ type: "object",
1252
+ properties: {
1253
+ block: { type: "object", additionalProperties: true },
1254
+ project_id: { type: "string" }
1255
+ },
1256
+ required: ["block", "project_id"],
1257
+ additionalProperties: false
1258
+ },
1259
+ async handler(args) {
1260
+ const projectId = args.project_id;
1261
+ const open = registry.get(projectId);
1262
+ if (!open)
1263
+ return { ok: false, error: "project-not-open", message: `projectId ${projectId} is not open` };
1264
+ try {
1265
+ const entry = catalog.writeCustomBlock(open.path, projectId, args.block);
1266
+ return { ok: true, block_id: entry.block_id, path: entry.path };
1267
+ } catch (e) {
1268
+ return { ok: false, error: "internal-error", message: e.message };
1269
+ }
1270
+ }
1271
+ },
1272
+ {
1273
+ name: "block.delete",
1274
+ description: 'Delete a custom block. Built-in blocks return { ok:false, error:"cannot-delete-builtin" }.',
1275
+ inputSchema: {
1276
+ type: "object",
1277
+ properties: {
1278
+ block_id: { type: "string" },
1279
+ project_id: { type: "string" }
1280
+ },
1281
+ required: ["block_id", "project_id"],
1282
+ additionalProperties: false
1283
+ },
1284
+ async handler(args) {
1285
+ const projectId = args.project_id;
1286
+ const open = registry.get(projectId);
1287
+ if (!open)
1288
+ return { ok: false, error: "project-not-open", message: `projectId ${projectId} is not open` };
1289
+ const r = catalog.deleteCustomBlock(open.path, projectId, args.block_id);
1290
+ if (r.ok)
1291
+ return { ok: true };
1292
+ if (r.reason === "cannot-delete-builtin")
1293
+ return { ok: false, error: "cannot-delete-builtin" };
1294
+ if (r.reason === "not-found")
1295
+ return { ok: false, error: "block-not-found" };
1296
+ return { ok: false, error: "internal-error", message: r.reason };
1297
+ }
1298
+ }
1299
+ ];
1300
+ }
1301
+
1302
+ // src/tools/archetype.ts
1303
+ function archetypeTools(catalog, registry, sidecar) {
1304
+ return [
1305
+ {
1306
+ name: "archetype.list",
1307
+ description: "List archetypes in the catalog. Project-scoped custom archetypes merge in when projectId is supplied.",
1308
+ inputSchema: {
1309
+ type: "object",
1310
+ properties: { projectId: { type: "string" } },
1311
+ additionalProperties: false
1312
+ },
1313
+ async handler(args) {
1314
+ const projectId = args.projectId;
1315
+ if (projectId) {
1316
+ const open = registry.get(projectId);
1317
+ if (open)
1318
+ catalog.refreshForProject(projectId, open.path);
1319
+ }
1320
+ const archetypes = catalog.listArchetypes({ projectId });
1321
+ return {
1322
+ ok: true,
1323
+ archetypes: archetypes.map((a) => ({
1324
+ archetype_id: a.archetype_id,
1325
+ name: a.name,
1326
+ source: a.source
1327
+ }))
1328
+ };
1329
+ }
1330
+ },
1331
+ {
1332
+ name: "archetype.get",
1333
+ description: "Fetch the full archetype.json body by id.",
1334
+ inputSchema: {
1335
+ type: "object",
1336
+ properties: {
1337
+ archetype_id: { type: "string" },
1338
+ projectId: { type: "string" }
1339
+ },
1340
+ required: ["archetype_id"],
1341
+ additionalProperties: false
1342
+ },
1343
+ async handler(args) {
1344
+ const projectId = args.projectId;
1345
+ if (projectId) {
1346
+ const open = registry.get(projectId);
1347
+ if (open)
1348
+ catalog.refreshForProject(projectId, open.path);
1349
+ }
1350
+ const a = catalog.getArchetype(args.archetype_id, projectId);
1351
+ if (!a)
1352
+ return { ok: false, error: "archetype-not-found", message: args.archetype_id };
1353
+ return { ok: true, archetype: a.body, source: a.source };
1354
+ }
1355
+ },
1356
+ {
1357
+ name: "archetype.instantiate_into_project",
1358
+ description: "Scaffold an archetype into an already-open project. Forwards to the sidecar, which materializes the archetype chain into the project cells. Returns archetype-not-found (WP-09) if no definition exists yet.",
1359
+ inputSchema: {
1360
+ type: "object",
1361
+ properties: {
1362
+ archetype_id: { type: "string" },
1363
+ project_id: { type: "string" }
1364
+ },
1365
+ required: ["archetype_id", "project_id"],
1366
+ additionalProperties: false
1367
+ },
1368
+ handler: (args) => callSidecar(sidecar, "archetype.instantiate_into_project", {
1369
+ projectId: args.project_id,
1370
+ archetypeId: args.archetype_id
1371
+ })
1372
+ },
1373
+ {
1374
+ name: "archetype.save_custom",
1375
+ description: "Write a custom archetype under the project archetypes/ dir.",
1376
+ inputSchema: {
1377
+ type: "object",
1378
+ properties: {
1379
+ archetype: { type: "object", additionalProperties: true },
1380
+ project_id: { type: "string" }
1381
+ },
1382
+ required: ["archetype", "project_id"],
1383
+ additionalProperties: false
1384
+ },
1385
+ async handler(args) {
1386
+ const projectId = args.project_id;
1387
+ const open = registry.get(projectId);
1388
+ if (!open)
1389
+ return { ok: false, error: "project-not-open", message: `projectId ${projectId} is not open` };
1390
+ try {
1391
+ const entry = catalog.writeCustomArchetype(open.path, projectId, args.archetype);
1392
+ return { ok: true, archetype_id: entry.archetype_id, path: entry.path };
1393
+ } catch (e) {
1394
+ return { ok: false, error: "internal-error", message: e.message };
1395
+ }
1396
+ }
1397
+ },
1398
+ {
1399
+ name: "archetype.delete",
1400
+ description: 'Delete a custom archetype. Built-ins return { ok:false, error:"cannot-delete-builtin" }.',
1401
+ inputSchema: {
1402
+ type: "object",
1403
+ properties: {
1404
+ archetype_id: { type: "string" },
1405
+ project_id: { type: "string" }
1406
+ },
1407
+ required: ["archetype_id", "project_id"],
1408
+ additionalProperties: false
1409
+ },
1410
+ async handler(args) {
1411
+ const projectId = args.project_id;
1412
+ const open = registry.get(projectId);
1413
+ if (!open)
1414
+ return { ok: false, error: "project-not-open", message: `projectId ${projectId} is not open` };
1415
+ const r = catalog.deleteCustomArchetype(open.path, projectId, args.archetype_id);
1416
+ if (r.ok)
1417
+ return { ok: true };
1418
+ if (r.reason === "cannot-delete-builtin")
1419
+ return { ok: false, error: "cannot-delete-builtin" };
1420
+ if (r.reason === "not-found")
1421
+ return { ok: false, error: "archetype-not-found" };
1422
+ return { ok: false, error: "internal-error", message: r.reason };
1423
+ }
1424
+ }
1425
+ ];
1426
+ }
1427
+
1428
+ // src/tools/index.ts
1429
+ function buildTools(opts) {
1430
+ const { sidecar, catalog, registry } = opts;
1431
+ return [
1432
+ ...projectTools(sidecar, registry),
1433
+ ...storyboardTools(sidecar),
1434
+ ...anchorTools(sidecar),
1435
+ ...assetTools(sidecar),
1436
+ ...compositionTools(sidecar, registry),
1437
+ ...renderTools(sidecar),
1438
+ ...exportTools(sidecar),
1439
+ ...blockTools(catalog, registry),
1440
+ ...archetypeTools(catalog, registry, sidecar)
1441
+ ];
1442
+ }
1443
+
1444
+ // src/index.ts
1445
+ var NAME = "studio-mcp";
1446
+ var VERSION = "0.0.0";
1447
+
1448
+ class InMemoryRegistry {
1449
+ map = new Map;
1450
+ set(projectId, entry) {
1451
+ this.map.set(projectId, entry);
1452
+ }
1453
+ get(projectId) {
1454
+ return this.map.get(projectId);
1455
+ }
1456
+ delete(projectId) {
1457
+ return this.map.delete(projectId);
1458
+ }
1459
+ values() {
1460
+ return this.map.values();
1461
+ }
1462
+ keys() {
1463
+ return this.map.keys();
1464
+ }
1465
+ }
1466
+ async function main() {
1467
+ const sidecar = new SidecarClient;
1468
+ const catalog = new Catalog;
1469
+ const registry = new InMemoryRegistry;
1470
+ sidecar.start();
1471
+ const tools = buildTools({ sidecar, catalog, registry });
1472
+ const byName = new Map(tools.map((t) => [t.name, t]));
1473
+ const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: {}, logging: {} } });
1474
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1475
+ tools: tools.map((t) => ({
1476
+ name: t.name,
1477
+ description: t.description,
1478
+ inputSchema: t.inputSchema
1479
+ }))
1480
+ }));
1481
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1482
+ const { name, arguments: args } = req.params;
1483
+ const tool = byName.get(name);
1484
+ if (!tool) {
1485
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
1486
+ }
1487
+ try {
1488
+ const result = await tool.handler(args ?? {});
1489
+ return {
1490
+ content: [{ type: "text", text: JSON.stringify(result) }],
1491
+ isError: result.ok === false
1492
+ };
1493
+ } catch (err) {
1494
+ if (err instanceof McpError)
1495
+ throw err;
1496
+ const msg = err instanceof Error ? err.message : String(err);
1497
+ return {
1498
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: "internal-error", message: msg }) }],
1499
+ isError: true
1500
+ };
1501
+ }
1502
+ });
1503
+ sidecar.onEvent((evt) => {
1504
+ server.sendLoggingMessage({
1505
+ level: "info",
1506
+ logger: "studio-mcp/sidecar-event",
1507
+ data: evt
1508
+ }).catch((err) => {
1509
+ process.stderr.write(`[studio-mcp] failed to forward event: ${err.message}
1510
+ `);
1511
+ });
1512
+ });
1513
+ process.stderr.write(`[studio-mcp] ready name=${NAME} pid=${process.pid}
1514
+ `);
1515
+ const transport = new StdioServerTransport;
1516
+ await server.connect(transport);
1517
+ const shutdown = async (sig) => {
1518
+ process.stderr.write(`[studio-mcp] received ${sig}, shutting down
1519
+ `);
1520
+ try {
1521
+ await sidecar.stop();
1522
+ } catch {}
1523
+ try {
1524
+ await server.close();
1525
+ } catch {}
1526
+ process.exit(0);
1527
+ };
1528
+ process.on("SIGINT", () => void shutdown("SIGINT"));
1529
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
1530
+ }
1531
+ main().catch((err) => {
1532
+ process.stderr.write(`[studio-mcp] fatal: ${err.stack ?? String(err)}
1533
+ `);
1534
+ process.exit(1);
1535
+ });