@tandryukha/aidemo 0.18.0 → 0.20.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.
package/README.md CHANGED
@@ -154,7 +154,7 @@ A bundled fixture store (search → results → cart → checkout) that renders
154
154
  finished demo with zero external dependencies:
155
155
 
156
156
  ```bash
157
- npm install # Node 22+, system Chrome, ffmpeg on PATH
157
+ npm install # Node 22.12+, system Chrome, ffmpeg on PATH
158
158
  node examples/local-demo/serve.mjs # terminal 1: fixture on :8787
159
159
  node bin/aidemo.mjs render examples/local-demo --headless # terminal 2
160
160
  open examples/local-demo/output/final-demo.mp4 # xdg-open on Linux, start on Windows
@@ -254,7 +254,7 @@ Codex).
254
254
 
255
255
  ## Setup
256
256
 
257
- Prereqs: **Node 22+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`. The
257
+ Prereqs: **Node 22.12+**, **Google Chrome**, **ffmpeg + ffprobe** on `PATH`. The
258
258
  first take also fetches Playwright's ~2 MB ffmpeg helper, which encodes the
259
259
  recording (once per Playwright version; `AIDEMO_NO_AUTO_INSTALL=1` opts out).
260
260
  Developed and tested on macOS; Linux works for the default (Playwright) capture
@@ -304,9 +304,9 @@ demos/<name>/ ← your working area (untracked; scaffold with `aidemo i
304
304
  ffmpeg helper that encodes the take; off with `AIDEMO_NO_AUTO_INSTALL=1`), and
305
305
  `github.com` (your own `gh`, for `aidemo feedback`). Recording/composing are
306
306
  otherwise fully local. The MCP server is **stdio-only** — no listener.
307
- - **Small, auditable surface:** ~20 source files, 7 runtime deps, MIT. Pin an
308
- immutable ref if you're wary of the moving `#stable` tag:
309
- `npx -y github:tandryukha/aidemo#v0.14.0` (any released tag).
307
+ - **Small, auditable surface:** ~50 source files, 7 runtime deps (11 packages
308
+ installed in all), MIT. Pin an immutable ref if you're wary of the moving
309
+ `#stable` tag: `npx -y github:tandryukha/aidemo#v0.20.0` (any released tag).
310
310
  - Full detail: [docs/LOCAL_MODELS.md](docs/LOCAL_MODELS.md#what-leaves-the-machine)
311
311
  · report vulnerabilities privately per [SECURITY.md](SECURITY.md).
312
312
 
package/bin/aidemo.mjs CHANGED
@@ -7,15 +7,16 @@ import { existsSync } from "node:fs";
7
7
  import { dirname, resolve } from "node:path";
8
8
 
9
9
  // Fail fast on an unsupported Node with the fix, instead of an arbitrary crash
10
- // deep inside a dependency (openai 7 needs Node 22). Node 20 reached
11
- // end-of-life on 2026-04-30. Keep in sync with package.json "engines".
12
- const MIN_NODE_MAJOR = 22;
13
- const nodeMajor = Number(process.versions.node.split(".")[0]);
14
- if (nodeMajor < MIN_NODE_MAJOR) {
10
+ // deep inside a dependency (openai 7 needs Node 22, commander 15 22.12). Node
11
+ // 20 reached end-of-life on 2026-04-30. Keep in sync with package.json
12
+ // "engines" and the doctor check (src/distribute.ts).
13
+ const MIN_NODE = [22, 12];
14
+ const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
15
+ if (nodeMajor < MIN_NODE[0] || (nodeMajor === MIN_NODE[0] && nodeMinor < MIN_NODE[1])) {
15
16
  console.error(
16
- `aidemo: Node.js ${MIN_NODE_MAJOR}+ is required (found ${process.version}). ` +
17
- `Install Node ${MIN_NODE_MAJOR} LTS or newer (nvm install ${MIN_NODE_MAJOR}; in CI, ` +
18
- `actions/setup-node with node-version: ${MIN_NODE_MAJOR}) and re-run.`
17
+ `aidemo: Node.js ${MIN_NODE.join(".")}+ is required (found ${process.version}). ` +
18
+ `Install Node ${MIN_NODE[0]} LTS or newer (nvm install ${MIN_NODE[0]}; in CI, ` +
19
+ `actions/setup-node with node-version: ${MIN_NODE[0]}) and re-run.`
19
20
  );
20
21
  process.exit(1);
21
22
  }
package/docs/AUTHORING.md CHANGED
@@ -625,8 +625,8 @@ artifacts in `output/walkthrough/`: `index.html` (one card per scene with
625
625
  its payoff frame, title, narration and a jump-to-time button over the video;
626
626
  ← / → keys step through, Enter plays from a step), `guide.md` (the same as a
627
627
  README/SOP section: heading + frame + narration per scene), `scene-NN-<id>.png`
628
- frames, the SRT/VTT captions (timed to the video, like the sidecars), and
629
- `walkthrough.json`. Frames come from the
628
+ frames, the SRT/VTT captions (timed to the video, like the sidecars; the
629
+ page's player offers them as a captions track), and `walkthrough.json`. Frames come from the
630
630
  **final** video, so they carry zoom, cursor, overlays and the frame. Scene
631
631
  `title`s name the steps (falling back to ids).
632
632
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tandryukha/aidemo",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "aidemo": "bin/aidemo.mjs"
9
9
  },
10
10
  "engines": {
11
- "node": ">=22"
11
+ "node": ">=22.12"
12
12
  },
13
13
  "publishConfig": {
14
14
  "access": "public"
@@ -31,6 +31,7 @@
31
31
  "build": "tsc --noEmit",
32
32
  "serve-example": "node examples/local-demo/serve.mjs",
33
33
  "mcp-smoke": "node test/mcp-smoke.mjs",
34
+ "test": "tsx --test test/issue-64.mjs test/issue-89.mjs test/runtime-setup.mjs test/mcp-schemas.mjs test/mcp-stdio.mjs",
34
35
  "test:blank-open": "tsx --test test/blank-open.mjs",
35
36
  "mcp-env-test": "node test/mcp-env.mjs",
36
37
  "guard-openai-key": "tsx scripts/guard-openai-key.ts",
@@ -38,16 +39,17 @@
38
39
  "check:plugin-skill": "diff -q .claude/skills/record-demo/SKILL.md plugins/record-demo/skills/record-demo/SKILL.md"
39
40
  },
40
41
  "dependencies": {
41
- "@modelcontextprotocol/sdk": "1.32.2",
42
- "commander": "^12.1.0",
42
+ "@modelcontextprotocol/server": "2.4.0",
43
+ "commander": "^15.0.0",
43
44
  "openai": "^7.47.0",
44
- "playwright": "^1.55.1",
45
+ "playwright": "^1.64.1",
45
46
  "re2js": "2.9.1",
46
- "tsx": "^4.23.1",
47
+ "tsx": "^4.23.15",
47
48
  "zod": "^4.6.7"
48
49
  },
49
50
  "devDependencies": {
50
- "@types/node": "^22.20.1",
51
- "typescript": "^5.6.0"
51
+ "@modelcontextprotocol/client": "2.4.0",
52
+ "@types/node": "^26.6.6",
53
+ "typescript": "^7.0.2"
52
54
  }
53
55
  }
@@ -75,6 +75,21 @@ export function mp4Language(tag: string | undefined): string {
75
75
  return ISO639_2T[primary] ?? "und";
76
76
  }
77
77
 
78
+ /**
79
+ * `srclang` + human label for an HTML `<track>` of captions in `tag` (a BCP-47
80
+ * code: "en", "en-GB", "de"), e.g. { srclang: "en-GB", label: "British English" }.
81
+ */
82
+ export function htmlTrackLanguage(tag: string | undefined): { srclang: string; label: string } {
83
+ const srclang = tag?.trim() || "en";
84
+ let label = srclang;
85
+ try {
86
+ label = new Intl.DisplayNames(["en"], { type: "language" }).of(srclang) ?? srclang;
87
+ } catch {
88
+ // not a valid BCP-47 tag — keep the raw code as the label
89
+ }
90
+ return { srclang, label };
91
+ }
92
+
78
93
  /** Track handler types that carry subtitles/captions in ISO-BMFF / QuickTime. */
79
94
  const SUBTITLE_HANDLERS = new Set(["sbtl", "text", "subt"]);
80
95
 
package/src/capture.ts CHANGED
@@ -348,7 +348,7 @@ interface ObsPending {
348
348
  }
349
349
 
350
350
  /**
351
- * Minimal obs-websocket v5 client on Node's global WebSocket (Node 22+).
351
+ * Minimal obs-websocket v5 client on Node's global WebSocket.
352
352
  * Assumes the current OBS scene captures the FULL primary display (Display
353
353
  * Capture) — the recorder crops to the viewport afterwards, same as native.
354
354
  */
@@ -366,11 +366,6 @@ export class ObsCapture implements CaptureProvider {
366
366
  }
367
367
 
368
368
  async start(): Promise<void> {
369
- if (typeof WebSocket === "undefined") {
370
- throw new Error(
371
- "OBS capture needs Node 22+ (built-in WebSocket). Use --capture native instead."
372
- );
373
- }
374
369
  await this.connect();
375
370
  await this.request("StartRecord", {});
376
371
  await sleep(500); // let OBS write its first frames
package/src/distribute.ts CHANGED
@@ -685,10 +685,12 @@ export interface DoctorReport {
685
685
  ok: boolean;
686
686
  }
687
687
 
688
- /** Oldest supported Node major (bin/aidemo.mjs fails fast below it; package.json "engines"). */
689
- const MIN_NODE_MAJOR = 22;
690
- const nodeSupported = (version: string): boolean =>
691
- Number(version.replace(/^v/, "").split(".")[0]) >= MIN_NODE_MAJOR;
688
+ /** Oldest supported Node (bin/aidemo.mjs fails fast below it; package.json "engines"). */
689
+ const MIN_NODE: [number, number] = [22, 12];
690
+ export const nodeSupported = (version: string): boolean => {
691
+ const [major, minor] = version.replace(/^v/, "").split(".").map(Number);
692
+ return major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
693
+ };
692
694
 
693
695
  /** Build the structured preflight report (used by CLI doctor + MCP doctor). */
694
696
  export async function doctorReport(dir: string | undefined): Promise<DoctorReport> {
@@ -794,7 +796,7 @@ export async function doctor(dir: string | undefined): Promise<void> {
794
796
  val ? ok(`${label}: ${val}`) : fail(`${label}: NOT FOUND`);
795
797
 
796
798
  if (nodeSupported(r.node)) ok(`node: ${r.node}`);
797
- else fail(`node: ${r.node} — aidemo needs Node ${MIN_NODE_MAJOR}+ (Node 20 is end-of-life)`);
799
+ else fail(`node: ${r.node} — aidemo needs Node ${MIN_NODE.join(".")}+ (Node 20 is end-of-life)`);
798
800
  line("ffmpeg", r.ffmpeg);
799
801
  if (r.browser.source === "default") {
800
802
  line("chrome", r.chrome);
package/src/embed.ts CHANGED
@@ -12,10 +12,12 @@
12
12
  * hits the network beyond the local `git` invocation, and never renders.
13
13
  */
14
14
  import { execFile } from "node:child_process";
15
+ import { readFile } from "node:fs/promises";
15
16
  import { promisify } from "node:util";
16
17
  import { resolve, isAbsolute, basename } from "node:path";
17
18
  import { exists } from "./util.js";
18
19
  import { UPSTREAM_REPO } from "./distribute.js";
20
+ import { htmlTrackLanguage } from "./caption-track.js";
19
21
 
20
22
  const execFileAsync = promisify(execFile);
21
23
 
@@ -49,6 +51,8 @@ export interface EmbedResult {
49
51
  still: string;
50
52
  /** GitHub Pages MP4 (correct video/mp4 MIME for <video>) — needs Pages on the branch. */
51
53
  pagesMp4: string;
54
+ /** GitHub Pages WebVTT captions, timed to the video — the <video> embed's <track>. */
55
+ pagesVtt: string;
52
56
  /** GitHub Pages project-site base. */
53
57
  pagesBase: string;
54
58
  };
@@ -140,12 +144,25 @@ export async function buildEmbed(
140
144
  // GitHub Pages project-site host is the lowercased owner login.
141
145
  const pagesBase = `https://${owner.toLowerCase()}.github.io/${repo}`;
142
146
  const pagesMp4 = `${pagesBase}/media/${demo}/latest.mp4`;
147
+ const pagesVtt = `${pagesBase}/media/${demo}/latest.vtt`;
148
+ // Track language = the storyboard's narration language (best-effort read).
149
+ let language: string | undefined;
150
+ try {
151
+ const sb = JSON.parse(await readFile(resolve(demoDir, "generated", "storyboard.json"), "utf8"));
152
+ if (typeof sb?.language === "string") language = sb.language;
153
+ } catch {
154
+ // no storyboard here (or unreadable) — English
155
+ }
156
+ const { srclang, label } = htmlTrackLanguage(language);
143
157
 
144
158
  const markdownGif = `![${demo} demo](${gif})`;
145
159
  const markdownStill = `![${demo}](${stillUrl})`;
160
+ // crossorigin: the page embedding this is on another origin than Pages, and
161
+ // a cross-origin <track> only loads in CORS mode (Pages sends ACAO: *).
146
162
  const htmlVideo =
147
- `<video controls muted loop playsinline width="960" poster="${stillUrl}">\n` +
163
+ `<video controls muted loop playsinline width="960" crossorigin="anonymous" poster="${stillUrl}">\n` +
148
164
  ` <source src="${pagesMp4}" type="video/mp4" />\n` +
165
+ ` <track kind="captions" src="${pagesVtt}" srclang="${srclang}" label="${label}" />\n` +
149
166
  ` <a href="${gif}">▶ Watch the ${demo} demo</a>\n` +
150
167
  `</video>`;
151
168
 
@@ -158,7 +175,7 @@ export async function buildEmbed(
158
175
  demo,
159
176
  branch: DEMO_MEDIA_BRANCH,
160
177
  still,
161
- urls: { gif, mp4, still: stillUrl, pagesMp4, pagesBase },
178
+ urls: { gif, mp4, still: stillUrl, pagesMp4, pagesVtt, pagesBase },
162
179
  snippets: { markdownGif, markdownStill, htmlVideo },
163
180
  workflow: {
164
181
  path: PUBLISH_WORKFLOW_REL,
package/src/mcp/jobs.ts CHANGED
@@ -87,9 +87,6 @@ export class JobManager {
87
87
  private jobs = new Map<string, Job>();
88
88
  private activeId: string | null = null;
89
89
 
90
- /** Best-effort per-line forwarding (e.g. MCP logging notifications). */
91
- onLine?: (job: Job, cleanChunk: string) => void;
92
-
93
90
  /** Start a job or throw JobBusyError if one is already running. */
94
91
  startJob(
95
92
  kind: JobKind,
@@ -284,6 +281,5 @@ export class JobManager {
284
281
  if (job.ring.length > RING_MAX) {
285
282
  job.ring.splice(0, job.ring.length - RING_MAX);
286
283
  }
287
- this.onLine?.(job, chunk);
288
284
  }
289
285
  }
package/src/mcp/server.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
3
  import { z } from "zod";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { resolve } from "node:path";
@@ -81,6 +81,44 @@ function storyboardJsonSchema(): Record<string, unknown> {
81
81
  }) as Record<string, unknown>;
82
82
  }
83
83
 
84
+ /**
85
+ * Tool input schemas advertise `additionalProperties: false` on every object,
86
+ * as they did before the zod 4 migration: the MCP SDK converts zod schemas
87
+ * through zod's Standard JSON Schema with io:"input", which leaves objects
88
+ * open. Metadata only — zod still strips unknown arguments at runtime,
89
+ * exactly as before. (A tool without arguments registers no input schema;
90
+ * the SDK lists it as an empty object.)
91
+ */
92
+ function closedInput<Shape extends z.ZodRawShape>(shape: Shape) {
93
+ for (const field of Object.values(shape)) closeObjects(field);
94
+ return z.object(shape).meta({ additionalProperties: false });
95
+ }
96
+
97
+ /** Mark every object schema reachable from `schema` closed (keeps existing metadata). */
98
+ function closeObjects(schema: z.core.$ZodType, seen = new Set<unknown>()): void {
99
+ if (seen.has(schema)) return;
100
+ seen.add(schema);
101
+ const def = schema._zod.def as {
102
+ type: string;
103
+ shape?: Record<string, z.core.$ZodType>;
104
+ innerType?: z.core.$ZodType;
105
+ element?: z.core.$ZodType;
106
+ options?: z.core.$ZodType[];
107
+ valueType?: z.core.$ZodType;
108
+ };
109
+ if (def.type === "object") {
110
+ z.globalRegistry.add(schema, { ...z.globalRegistry.get(schema), additionalProperties: false });
111
+ }
112
+ const children = [
113
+ ...Object.values(def.shape ?? {}),
114
+ ...(def.options ?? []),
115
+ def.innerType,
116
+ def.element,
117
+ def.valueType,
118
+ ];
119
+ for (const child of children) if (child) closeObjects(child, seen);
120
+ }
121
+
84
122
  type ToolResult = {
85
123
  content: Array<{ type: "text"; text: string }>;
86
124
  structuredContent?: Record<string, unknown>;
@@ -243,29 +281,20 @@ function throwIfAborted(signal: AbortSignal): void {
243
281
  if (signal.aborted) throw new CanceledError("canceled between stages");
244
282
  }
245
283
 
246
- export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
247
- const jobs = new JobManager();
284
+ /**
285
+ * One MCP server instance over the process-wide job manager. Under stdio the
286
+ * SDK may build more than one instance per process (a discarded
287
+ * `server/discover` probe, then the connection's instance), so job state lives
288
+ * in the shared `jobs`, never in an instance. Progress reaches clients through
289
+ * job_status polling; engine logging goes to stderr and the job's log file
290
+ * (MCP logging notifications are deprecated as of protocol 2026-07-28).
291
+ */
292
+ export function buildMcpServer(jobs: JobManager = new JobManager()): { server: McpServer; jobs: JobManager } {
248
293
  const server = new McpServer(
249
294
  { name: "aidemo", version: engineVersion() },
250
- { instructions: INSTRUCTIONS, capabilities: { logging: {} } }
295
+ { instructions: INSTRUCTIONS }
251
296
  );
252
297
 
253
- // Best-effort log forwarding (throttled); job_status polling is the source
254
- // of truth — many clients don't surface logging notifications.
255
- const lastSent = new Map<string, number>();
256
- jobs.onLine = (job, chunk) => {
257
- const now = Date.now();
258
- if (now - (lastSent.get(job.jobId) ?? 0) < 1000) return;
259
- lastSent.set(job.jobId, now);
260
- void server
261
- .sendLoggingMessage({
262
- level: "info",
263
- logger: "aidemo",
264
- data: { jobId: job.jobId, stage: job.stage, line: chunk.trim() },
265
- })
266
- .catch(() => {});
267
- };
268
-
269
298
  const guidePath = guideFilePath();
270
299
 
271
300
  server.registerTool(
@@ -279,21 +308,21 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
279
308
  `\`topic\` (${GUIDE_TOPIC_NAMES.join(" | ")}, or any H2 heading prefix) ` +
280
309
  "for one slice instead of the whole ~1000-line guide; start with " +
281
310
  "`core`, then fetch `attention`/`polish`/`chatgpt-apps` as needed.",
282
- inputSchema: {
311
+ inputSchema: closedInput({
283
312
  topic: z
284
313
  .string()
285
314
  .optional()
286
315
  .describe(
287
316
  `Slice: ${GUIDE_TOPIC_NAMES.join(", ")} — or an H2 heading prefix. Omit for the full guide.`
288
317
  ),
289
- },
290
- outputSchema: {
318
+ }),
319
+ outputSchema: z.object({
291
320
  guide: z.string(),
292
321
  engineVersion: z.string(),
293
322
  topic: z.string().optional(),
294
323
  topics: z.array(z.string()),
295
324
  sections: z.array(z.string()),
296
- },
325
+ }),
297
326
  annotations: { readOnlyHint: true },
298
327
  },
299
328
  async ({ topic }) => {
@@ -329,8 +358,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
329
358
  description:
330
359
  "JSON Schema for storyboard.json, generated from the engine's own " +
331
360
  "zod schema — the exact contract the engine validates against.",
332
- inputSchema: {},
333
- outputSchema: { schema: z.record(z.string(), z.unknown()) },
361
+ outputSchema: z.object({ schema: z.record(z.string(), z.unknown()) }),
334
362
  annotations: { readOnlyHint: true },
335
363
  },
336
364
  async () =>
@@ -348,14 +376,14 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
348
376
  "anything. Pass exactly one of: dir (uses generated/storyboard.json), " +
349
377
  "path (a storyboard file), or json (storyboard JSON as a string). " +
350
378
  "relaxed makes narration optional (probe semantics).",
351
- inputSchema: {
379
+ inputSchema: closedInput({
352
380
  dir: z.string().optional().describe("demo dir → generated/storyboard.json"),
353
381
  path: z.string().optional().describe("path to a storyboard .json file"),
354
382
  json: z.string().optional().describe("storyboard JSON as a string"),
355
383
  relaxed: z.boolean().optional(),
356
384
  params: PARAMS_INPUT,
357
- },
358
- outputSchema: {
385
+ }),
386
+ outputSchema: z.object({
359
387
  valid: z.boolean(),
360
388
  storyboardPath: z.string().optional(),
361
389
  title: z.string().optional(),
@@ -365,7 +393,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
365
393
  ),
366
394
  warnings: z.array(z.string()),
367
395
  lint: z.array(LINT_ISSUE_SHAPE).optional(),
368
- },
396
+ }),
369
397
  annotations: { readOnlyHint: true },
370
398
  },
371
399
  async (args) => {
@@ -441,14 +469,14 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
441
469
  "Run it after every storyboard edit, before probe/render. Pass exactly " +
442
470
  "one of dir / path / json. lang lints a narrations[lang] translation at " +
443
471
  "that language's speaking rate.",
444
- inputSchema: {
472
+ inputSchema: closedInput({
445
473
  dir: z.string().optional().describe("demo dir → generated/storyboard.json"),
446
474
  path: z.string().optional().describe("path to a storyboard .json file"),
447
475
  json: z.string().optional().describe("storyboard JSON as a string"),
448
476
  lang: z.string().optional(),
449
477
  params: PARAMS_INPUT,
450
- },
451
- outputSchema: {
478
+ }),
479
+ outputSchema: z.object({
452
480
  issues: z.array(LINT_ISSUE_SHAPE),
453
481
  estimate: z.array(
454
482
  z.object({
@@ -462,7 +490,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
462
490
  ),
463
491
  narrationTotalMs: z.number(),
464
492
  wordsPerSec: z.number(),
465
- },
493
+ }),
466
494
  annotations: { readOnlyHint: true },
467
495
  },
468
496
  async (args) => {
@@ -508,7 +536,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
508
536
  "With fromUrl the page is inspected first (no LLM): its headings become " +
509
537
  "scenes and its unique selectors become the beats + a `_candidates` list — " +
510
538
  "you then write the narration and turn the candidate hover into the real click.",
511
- inputSchema: {
539
+ inputSchema: closedInput({
512
540
  name: z.string().describe("demo name (creates demos/<name>/)"),
513
541
  dir: z.string().optional(),
514
542
  force: z.boolean().optional(),
@@ -519,12 +547,12 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
519
547
  .object({ width: z.number(), height: z.number() })
520
548
  .optional()
521
549
  .describe("fromUrl: viewport (default 1280x720)"),
522
- },
523
- outputSchema: {
550
+ }),
551
+ outputSchema: z.object({
524
552
  demoDir: z.string(),
525
553
  storyboardPath: z.string(),
526
554
  briefPath: z.string(),
527
- },
555
+ }),
528
556
  },
529
557
  async (args) => {
530
558
  // fromUrl launches Chrome: honour the target repo's .env (#77).
@@ -559,10 +587,10 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
559
587
  "LLM-only servers like Ollama), API key, playwright + its ffmpeg " +
560
588
  "helper (records the take; installed on the first take unless " +
561
589
  "AIDEMO_NO_AUTO_INSTALL=1), installed skill.",
562
- inputSchema: {
590
+ inputSchema: closedInput({
563
591
  dir: z.string().optional().describe("repo to check for an installed skill"),
564
- },
565
- outputSchema: {
592
+ }),
593
+ outputSchema: z.object({
566
594
  engineVersion: z.string(),
567
595
  node: z.string(),
568
596
  ffmpeg: z.string().nullable(),
@@ -610,7 +638,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
610
638
  .nullable(),
611
639
  }),
612
640
  ok: z.boolean(),
613
- },
641
+ }),
614
642
  annotations: { readOnlyHint: true },
615
643
  },
616
644
  async (args) => {
@@ -633,7 +661,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
633
661
  "Owner/repo come from the repo's origin remote; the demo name from the " +
634
662
  "directory basename. Pair with the demo-publish workflow so CI keeps " +
635
663
  "the URLs fresh. See docs/EMBEDS.md.",
636
- inputSchema: {
664
+ inputSchema: closedInput({
637
665
  dir: DIR_INPUT,
638
666
  repo: z
639
667
  .string()
@@ -643,8 +671,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
643
671
  .string()
644
672
  .optional()
645
673
  .describe("still-frame basename under stills/ (default: poster)"),
646
- },
647
- outputSchema: {
674
+ }),
675
+ outputSchema: z.object({
648
676
  owner: z.string(),
649
677
  repo: z.string(),
650
678
  demo: z.string(),
@@ -655,6 +683,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
655
683
  mp4: z.string(),
656
684
  still: z.string(),
657
685
  pagesMp4: z.string(),
686
+ pagesVtt: z.string().describe("captions for the <video> embed's <track>"),
658
687
  pagesBase: z.string(),
659
688
  }),
660
689
  snippets: z.object({
@@ -667,7 +696,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
667
696
  present: z.boolean(),
668
697
  template: z.string(),
669
698
  }),
670
- },
699
+ }),
671
700
  annotations: { readOnlyHint: true },
672
701
  },
673
702
  async (args) => {
@@ -698,7 +727,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
698
727
  "network call. Call this whenever something felt broken, surprising, " +
699
728
  "or needed a workaround; skip it if the session was clean. Set dryRun " +
700
729
  "to preview the assembled title/body without filing anything.",
701
- inputSchema: {
730
+ inputSchema: closedInput({
702
731
  title: z.string().describe("short issue title"),
703
732
  body: z
704
733
  .string()
@@ -719,15 +748,15 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
719
748
  .boolean()
720
749
  .optional()
721
750
  .describe("assemble the title/body but don't file anything — preview only"),
722
- },
723
- outputSchema: {
751
+ }),
752
+ outputSchema: z.object({
724
753
  filed: z.boolean(),
725
754
  title: z.string(),
726
755
  body: z.string(),
727
756
  url: z.string().optional(),
728
757
  localPath: z.string().optional(),
729
758
  message: z.string(),
730
- },
759
+ }),
731
760
  },
732
761
  async (args) => {
733
762
  const demoDir = args.dir ? resolve(args.dir) : null;
@@ -751,11 +780,11 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
751
780
  "compose), log tail, and final result or error (with failure-artifact " +
752
781
  "paths and, on a genuine failure, a feedbackHint pointing at the " +
753
782
  "feedback tool) of a pipeline job.",
754
- inputSchema: {
783
+ inputSchema: closedInput({
755
784
  jobId: z.string(),
756
785
  tailLines: z.number().optional().describe("log lines to return (default 40)"),
757
- },
758
- outputSchema: JOB_STATUS_SHAPE,
786
+ }),
787
+ outputSchema: z.object(JOB_STATUS_SHAPE),
759
788
  annotations: { readOnlyHint: true },
760
789
  },
761
790
  async (args) => {
@@ -776,8 +805,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
776
805
  {
777
806
  title: "List jobs",
778
807
  description: "All jobs this server session, newest last.",
779
- inputSchema: {},
780
- outputSchema: {
808
+ outputSchema: z.object({
781
809
  jobs: z.array(
782
810
  z.object({
783
811
  jobId: z.string(),
@@ -789,7 +817,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
789
817
  endedAt: z.string().nullable(),
790
818
  })
791
819
  ),
792
- },
820
+ }),
793
821
  annotations: { readOnlyHint: true },
794
822
  },
795
823
  async () =>
@@ -814,8 +842,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
814
842
  "Best-effort cancel (checked between actions/scenes/stages). A " +
815
843
  "mid-record cancel salvages the partial timeline + footage. The job " +
816
844
  "settles asynchronously — poll job_status for the final state.",
817
- inputSchema: { jobId: z.string() },
818
- outputSchema: JOB_STATUS_SHAPE,
845
+ inputSchema: closedInput({ jobId: z.string() }),
846
+ outputSchema: z.object(JOB_STATUS_SHAPE),
819
847
  },
820
848
  async (args) => {
821
849
  const job = jobs.cancel(args.jobId);
@@ -840,8 +868,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
840
868
  description:
841
869
  `${description} Returns a jobId immediately — poll job_status. ` +
842
870
  `Rejects if another job is running.`,
843
- inputSchema: inputShape,
844
- outputSchema: JOB_START_SHAPE,
871
+ inputSchema: closedInput(inputShape),
872
+ outputSchema: z.object(JOB_START_SHAPE),
845
873
  },
846
874
  (async (args: z.infer<z.ZodObject<Shape>>) => {
847
875
  const project = new Project((args as { dir: string }).dir);
@@ -1019,19 +1047,19 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1019
1047
  "and selectors become scenes (cut at navigations and async payoffs), narration " +
1020
1048
  "is left as placeholders, and `_notes` lists what was approximated. No LLM, " +
1021
1049
  "no browser. Then write the narration and probe.",
1022
- inputSchema: {
1050
+ inputSchema: closedInput({
1023
1051
  file: z.string().describe("absolute path to trace.zip or the test file"),
1024
1052
  name: z.string().describe("demo name (creates demos/<name>/)"),
1025
1053
  dir: z.string().optional().describe("repo to scaffold into (default: server cwd)"),
1026
1054
  force: z.boolean().optional(),
1027
- },
1028
- outputSchema: {
1055
+ }),
1056
+ outputSchema: z.object({
1029
1057
  demoDir: z.string(),
1030
1058
  storyboardPath: z.string(),
1031
1059
  steps: z.number(),
1032
1060
  scenes: z.number(),
1033
1061
  notes: z.array(z.string()),
1034
- },
1062
+ }),
1035
1063
  },
1036
1064
  async (args) => {
1037
1065
  try {
@@ -1488,9 +1516,30 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1488
1516
  return { server, jobs };
1489
1517
  }
1490
1518
 
1491
- /** Run the stdio server until the client disconnects. Never resolves. */
1519
+ /**
1520
+ * The process's stdio wire, reporting when the connection ends. serveStdio
1521
+ * owns the wire and closes it when the connection is over (stdin EOF, a
1522
+ * broken stdout, an oversized message, or the connection's server closing);
1523
+ * discarding a `server/discover` probe instance never closes it.
1524
+ */
1525
+ class ProcessStdioTransport extends StdioServerTransport {
1526
+ constructor(private readonly onEnd: () => void) {
1527
+ super();
1528
+ }
1529
+
1530
+ override async close(): Promise<void> {
1531
+ await super.close();
1532
+ this.onEnd();
1533
+ }
1534
+ }
1535
+
1536
+ /**
1537
+ * Run the stdio server until the client disconnects. Never resolves.
1538
+ * serveStdio answers both protocol eras: a 2025-era client (`initialize`
1539
+ * handshake, what deployed clients speak) and a 2026-07-28 client.
1540
+ */
1492
1541
  export async function runMcpServer(): Promise<void> {
1493
- const { server, jobs } = buildMcpServer();
1542
+ const jobs = new JobManager();
1494
1543
  let shuttingDown = false;
1495
1544
  const shutdown = () => {
1496
1545
  if (shuttingDown) return;
@@ -1513,8 +1562,9 @@ export async function runMcpServer(): Promise<void> {
1513
1562
  };
1514
1563
  process.stdin.on("end", shutdown);
1515
1564
  process.stdin.on("close", shutdown);
1516
- server.server.onclose = shutdown;
1517
- const transport = new StdioServerTransport();
1518
- await server.connect(transport);
1565
+ serveStdio(() => buildMcpServer(jobs).server, {
1566
+ transport: new ProcessStdioTransport(shutdown),
1567
+ onerror: (err) => log(`mcp: ${err.message}`),
1568
+ });
1519
1569
  await new Promise(() => {}); // lives until shutdown() exits the process
1520
1570
  }
@@ -13,7 +13,8 @@ import { Project } from "./project.js";
13
13
  import { ComposeReportSchema, type Storyboard } from "./types.js";
14
14
  import { runFfmpeg } from "./ffmpeg.js";
15
15
  import { toSrt, toVtt } from "./captions.js";
16
- import { finalVideoCues } from "./caption-track.js";
16
+ import { finalVideoCues, htmlTrackLanguage } from "./caption-track.js";
17
+ import { resolveNarrationLanguage } from "./i18n.js";
17
18
  import { ensureDir, exists, readJson, writeJson, log, step } from "./util.js";
18
19
 
19
20
  export interface WalkthroughOptions {
@@ -150,7 +151,15 @@ export async function exportWalkthrough(
150
151
  );
151
152
 
152
153
  const index = resolve(dir, "index.html");
153
- await fs.writeFile(index, renderHtml(title, videoRel, scenes, report.durationMs));
154
+ // The page's player offers the shipped VTT as a captions track (off until
155
+ // the viewer turns it on, like the MP4's own subtitle track).
156
+ const track = captions.vtt
157
+ ? {
158
+ src: basename(captions.vtt),
159
+ ...htmlTrackLanguage(resolveNarrationLanguage(storyboard, project.lang)),
160
+ }
161
+ : undefined;
162
+ await fs.writeFile(index, renderHtml(title, videoRel, scenes, report.durationMs, track));
154
163
  const manifest = resolve(dir, "walkthrough.json");
155
164
  await writeJson(manifest, {
156
165
  title,
@@ -167,7 +176,8 @@ function renderHtml(
167
176
  title: string,
168
177
  videoRel: string,
169
178
  scenes: WalkthroughScene[],
170
- durationMs: number
179
+ durationMs: number,
180
+ track?: { src: string; srclang: string; label: string }
171
181
  ): string {
172
182
  const cards = scenes
173
183
  .map(
@@ -215,7 +225,11 @@ function renderHtml(
215
225
  <h1>${esc(title)}</h1>
216
226
  <div class="hint">${scenes.length} steps · ${mmss(durationMs)} · ← → to move between steps, Enter to play from a step</div>
217
227
  </header>
218
- <video id="v" src="${esc(videoRel)}" controls preload="metadata"></video>
228
+ <video id="v" src="${esc(videoRel)}" controls preload="metadata">${
229
+ track
230
+ ? `<track kind="captions" src="${esc(track.src)}" srclang="${esc(track.srclang)}" label="${esc(track.label)}">`
231
+ : ""
232
+ }</video>
219
233
  <main>${cards}
220
234
  </main>
221
235
  <script>