@writepanda/mcp 1.180.0 → 1.183.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,201 @@
1
+ // How the MCP server finds and reaches the running PandaStudio app.
2
+ //
3
+ // Two kinds of caller spawn this server:
4
+ //
5
+ // • External agents (Claude Desktop, Claude Code, Codex, Cursor…). The app
6
+ // may not be running, so a call reads the token/port files the app writes
7
+ // and, when nothing answers, launches the app and waits for it.
8
+ //
9
+ // • The app's own chat panel (PANDASTUDIO_CALLER=in-app). The app is running
10
+ // by definition: it spawned us, and it hands us its live port and token in
11
+ // the environment. Launching "the app" from here is always wrong, and
12
+ // waiting 60-90 s for it hid every real failure behind the client's
13
+ // generic "Request timed out". So in-app calls never launch anything and
14
+ // fail within seconds with the actual reason, prefixed with
15
+ // IN_APP_UNAVAILABLE so the app can show it (and a Retry) in the chat.
16
+ //
17
+ // Kept free of MCP-sdk imports so the pure parts are unit-testable.
18
+
19
+ import { readFile } from "node:fs/promises";
20
+ import http from "node:http";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ /** Prefix of every in-app "can't reach PandaStudio" tool error. The app's
25
+ * agent runner matches it (electron/agent/toolsUnavailable.ts). */
26
+ export const IN_APP_UNAVAILABLE = "PANDASTUDIO_TOOLS_UNAVAILABLE";
27
+
28
+ export function isInAppCaller(env = process.env) {
29
+ return env.PANDASTUDIO_CALLER === "in-app";
30
+ }
31
+
32
+ export function configDir(env = process.env, platform = process.platform) {
33
+ if (env.PANDASTUDIO_CONFIG_DIR) return env.PANDASTUDIO_CONFIG_DIR;
34
+ if (platform === "win32") {
35
+ const appData = env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
36
+ return path.join(appData, "pandastudio");
37
+ }
38
+ return path.join(os.homedir(), ".config", "pandastudio");
39
+ }
40
+
41
+ /** A text file's value without whitespace or a UTF-8 byte-order mark (an
42
+ * editor or a sync tool on Windows can add one, and Number() of
43
+ * "<BOM>7878" is NaN). */
44
+ export function cleanFileValue(raw) {
45
+ const s = String(raw);
46
+ return (s.charCodeAt(0) === 0xfeff ? s.slice(1) : s).trim();
47
+ }
48
+
49
+ /** Credentials the app passed in the environment (in-app spawns only). */
50
+ export function envCredentials(env = process.env) {
51
+ const port = Number(env.PANDASTUDIO_AUTOMATION_PORT);
52
+ const token = env.PANDASTUDIO_AUTOMATION_TOKEN;
53
+ if (!Number.isInteger(port) || port <= 0 || typeof token !== "string" || !token) return null;
54
+ return { port, token, source: "env" };
55
+ }
56
+
57
+ /** Credentials from the token/port files. Errors name the file and the cause. */
58
+ export async function fileCredentials(dir = configDir()) {
59
+ const portFile = path.join(dir, "port");
60
+ const tokenFile = path.join(dir, "token");
61
+ let portRaw;
62
+ let tokenRaw;
63
+ try {
64
+ portRaw = await readFile(portFile, "utf-8");
65
+ tokenRaw = await readFile(tokenFile, "utf-8");
66
+ } catch (err) {
67
+ const file = portRaw === undefined ? portFile : tokenFile;
68
+ throw new Error(`can't read ${file} (${err?.code ?? err?.message ?? err})`);
69
+ }
70
+ const port = Number(cleanFileValue(portRaw));
71
+ const token = cleanFileValue(tokenRaw);
72
+ if (!Number.isInteger(port) || port <= 0) {
73
+ throw new Error(`invalid port in ${portFile}: ${JSON.stringify(portRaw.slice(0, 20))}`);
74
+ }
75
+ if (!token) throw new Error(`empty token in ${tokenFile}`);
76
+ return { port, token, source: "files" };
77
+ }
78
+
79
+ /**
80
+ * One health probe of the automation server:
81
+ * up — it answered 200
82
+ * busy — connected (or still connecting) but no answer within `timeoutMs`:
83
+ * the app's main process is stalled
84
+ * down — refused / reset / any other socket error: nothing is listening
85
+ * `detail` says what happened, for error messages.
86
+ */
87
+ export function probeHealth(port, timeoutMs = 1500) {
88
+ return new Promise((resolve) => {
89
+ let settled = false;
90
+ const done = (state, detail) => {
91
+ if (settled) return;
92
+ settled = true;
93
+ clearTimeout(timer);
94
+ req.destroy();
95
+ resolve({ state, detail });
96
+ };
97
+ const req = http.get({ host: "127.0.0.1", port, path: "/v1/health", agent: false }, (res) => {
98
+ res.resume();
99
+ if (res.statusCode === 200) done("up", "HTTP 200");
100
+ else done("down", `HTTP ${res.statusCode} from /v1/health`);
101
+ });
102
+ req.on("error", (err) => done("down", err?.code ?? err?.message ?? String(err)));
103
+ const timer = setTimeout(() => done("busy", `no answer within ${timeoutMs} ms`), timeoutMs);
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Wait for the automation server, tolerating a stalled main process for up to
109
+ * `busyWaitMs`. Returns the last probe ({state:"up"} on success).
110
+ */
111
+ export async function waitForHealth(
112
+ port,
113
+ { probeMs = 1500, busyWaitMs = 30_000, onBusy, probe = probeHealth } = {},
114
+ ) {
115
+ const deadline = Date.now() + busyWaitMs;
116
+ let warned = false;
117
+ for (;;) {
118
+ const r = await probe(port, probeMs);
119
+ if (r.state === "up" || r.state === "down" || Date.now() >= deadline) return r;
120
+ if (!warned && onBusy) onBusy();
121
+ warned = true;
122
+ probeMs = Math.min(probeMs * 2, 5000);
123
+ }
124
+ }
125
+
126
+ function unavailable(reason) {
127
+ const err = new Error(`${IN_APP_UNAVAILABLE}: ${reason}`);
128
+ err.inAppUnavailable = true;
129
+ return err;
130
+ }
131
+
132
+ /**
133
+ * Resolve working credentials for an in-app call, or throw an
134
+ * IN_APP_UNAVAILABLE error with the reason. Never launches anything.
135
+ *
136
+ * Order: the credentials the app handed us (env), then the files (the app
137
+ * restarted its automation server on another port since it spawned us). The
138
+ * whole thing stays well inside the MCP client's 60 s request timeout.
139
+ */
140
+ export async function connectInApp({
141
+ env = process.env,
142
+ dir = configDir(env),
143
+ probeMs = 1500,
144
+ busyWaitMs = 20_000,
145
+ probe = probeHealth,
146
+ readFiles = fileCredentials,
147
+ } = {}) {
148
+ const attempts = [];
149
+ const candidates = [];
150
+ const fromEnv = envCredentials(env);
151
+ if (fromEnv) candidates.push(fromEnv);
152
+ let fileError = null;
153
+ try {
154
+ const fromFiles = await readFiles(dir);
155
+ if (!fromEnv || fromFiles.port !== fromEnv.port || fromFiles.token !== fromEnv.token) {
156
+ candidates.push(fromFiles);
157
+ }
158
+ } catch (err) {
159
+ fileError = err?.message ?? String(err);
160
+ }
161
+ if (candidates.length === 0) {
162
+ throw unavailable(
163
+ `PandaStudio didn't pass its automation address to the tools and ${fileError ?? "the token/port files are missing"}.`,
164
+ );
165
+ }
166
+ for (const c of candidates) {
167
+ const r = await waitForHealth(c.port, { probeMs, busyWaitMs, probe });
168
+ if (r.state === "up") return c;
169
+ attempts.push(
170
+ r.state === "busy"
171
+ ? `127.0.0.1:${c.port} accepted the connection but didn't answer for ${Math.round(busyWaitMs / 1000)} s (the app is busy or stuck)`
172
+ : `127.0.0.1:${c.port} (from ${c.source === "env" ? "the app" : dir}) is unreachable: ${r.detail}`,
173
+ );
174
+ }
175
+ throw unavailable(`PandaStudio's automation server didn't answer. ${attempts.join("; ")}.`);
176
+ }
177
+
178
+ /**
179
+ * Environment for LAUNCHING the desktop app. This server runs as Electron in
180
+ * node mode (ELECTRON_RUN_AS_NODE=1), and a child inherits that: spawning
181
+ * PandaStudio.exe / PandaStudio.app with it set starts a bare Node REPL that
182
+ * exits at once instead of the app, so an agent's "launch it and wait" always
183
+ * timed out. Drop it, and everything that only means something to this process.
184
+ */
185
+ export function appLaunchEnv(env = process.env) {
186
+ const out = {};
187
+ for (const [k, v] of Object.entries(env)) {
188
+ const upper = k.toUpperCase();
189
+ if (
190
+ upper === "ELECTRON_RUN_AS_NODE" ||
191
+ upper === "ELECTRON_NO_ATTACH_CONSOLE" ||
192
+ upper === "PANDASTUDIO_CALLER" ||
193
+ upper === "PANDASTUDIO_AUTOMATION_PORT" ||
194
+ upper === "PANDASTUDIO_AUTOMATION_TOKEN"
195
+ ) {
196
+ continue;
197
+ }
198
+ out[k] = v;
199
+ }
200
+ return out;
201
+ }
package/bin/server.mjs CHANGED
@@ -29,7 +29,6 @@
29
29
 
30
30
  import { spawn } from "node:child_process";
31
31
  import { existsSync, readFileSync } from "node:fs";
32
- import fs from "node:fs/promises";
33
32
  import http from "node:http";
34
33
  import os from "node:os";
35
34
  import path from "node:path";
@@ -37,78 +36,26 @@ import process from "node:process";
37
36
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
38
37
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
39
38
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
39
+ import {
40
+ appLaunchEnv,
41
+ cleanFileValue,
42
+ configDir,
43
+ connectInApp,
44
+ fileCredentials,
45
+ IN_APP_UNAVAILABLE,
46
+ isInAppCaller,
47
+ probeHealth,
48
+ waitForHealth,
49
+ } from "./appConnection.mjs";
40
50
 
41
51
  // ── Credentials + transport ───────────────────────────────────────────
52
+ //
53
+ // Finding and reaching the app lives in appConnection.mjs (unit-tested).
42
54
 
43
- function configDir() {
44
- if (process.env.PANDASTUDIO_CONFIG_DIR) return process.env.PANDASTUDIO_CONFIG_DIR;
45
- if (process.platform === "win32") {
46
- const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
47
- return path.join(appData, "pandastudio");
48
- }
49
- return path.join(os.homedir(), ".config", "pandastudio");
50
- }
51
-
52
- const TOKEN_FILE = path.join(configDir(), "token");
53
- const PORT_FILE = path.join(configDir(), "port");
55
+ const IN_APP = isInAppCaller();
54
56
 
55
57
  async function readCredentials() {
56
- const port = Number((await fs.readFile(PORT_FILE, "utf-8")).trim());
57
- const token = (await fs.readFile(TOKEN_FILE, "utf-8")).trim();
58
- if (!Number.isInteger(port) || port <= 0) throw new Error(`invalid port in ${PORT_FILE}`);
59
- if (!token) throw new Error(`empty token in ${TOKEN_FILE}`);
60
- return { port, token };
61
- }
62
-
63
- async function probeHealth(port, timeoutMs = 1500) {
64
- const ctrl = new AbortController();
65
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
66
- try {
67
- const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
68
- clearTimeout(t);
69
- return res.ok;
70
- } catch {
71
- clearTimeout(t);
72
- return false;
73
- }
74
- }
75
-
76
- /**
77
- * One health probe: "up" (answered), "busy" (the socket is open but no answer
78
- * within `timeoutMs`: the app's main process is stalled, e.g. creating an
79
- * editor window on a loaded machine) or "down" (connection refused / reset:
80
- * not running).
81
- */
82
- async function probeHealthState(port, timeoutMs) {
83
- const ctrl = new AbortController();
84
- const t = setTimeout(() => ctrl.abort(), timeoutMs);
85
- try {
86
- const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
87
- return res.ok ? "up" : "down";
88
- } catch {
89
- return ctrl.signal.aborted ? "busy" : "down";
90
- } finally {
91
- clearTimeout(t);
92
- }
93
- }
94
-
95
- /**
96
- * Is the app reachable? A busy app is waited for (up to `busyWaitMs`) rather
97
- * than reported as not running: a single 1.5 s probe used to fail whenever the
98
- * main process stalled for longer (window creation takes ~0.4 s idle and
99
- * several seconds under load), so agents saw "not reachable" at random.
100
- */
101
- async function appReachable(port, { probeMs = 1500, busyWaitMs = 30_000, onBusy } = {}) {
102
- const deadline = Date.now() + busyWaitMs;
103
- let warned = false;
104
- for (;;) {
105
- const s = await probeHealthState(port, probeMs);
106
- if (s === "up") return true;
107
- if (s === "down" || Date.now() >= deadline) return false;
108
- if (!warned && onBusy) onBusy();
109
- warned = true;
110
- probeMs = Math.min(probeMs * 2, 5000);
111
- }
58
+ return fileCredentials(configDir());
112
59
  }
113
60
 
114
61
  function findInstalledApp() {
@@ -116,7 +63,7 @@ function findInstalledApp() {
116
63
  // The app records its own location (it can live anywhere on Windows,
117
64
  // and in ~/Applications on a Mac).
118
65
  try {
119
- const recorded = readFileSync(path.join(configDir(), "app-path"), "utf-8").trim();
66
+ const recorded = cleanFileValue(readFileSync(path.join(configDir(), "app-path"), "utf-8"));
120
67
  if (recorded && existsSync(recorded)) return recorded;
121
68
  } catch {
122
69
  /* older app, or never launched */
@@ -159,14 +106,16 @@ async function autoLaunchAndWait(timeoutSec = 60) {
159
106
  const c = await readCredentials();
160
107
  // Busy (stalled main process) is not "not running": wait for it
161
108
  // instead of launching a second copy.
162
- if (await appReachable(c.port)) return c;
109
+ if ((await waitForHealth(c.port)).state === "up") return c;
163
110
  } catch {
164
111
  /* not running */
165
112
  }
166
113
 
167
114
  const bin = findInstalledApp();
168
115
  try {
169
- const child = spawn(bin, [], { detached: true, stdio: "ignore", env: process.env });
116
+ // appLaunchEnv drops ELECTRON_RUN_AS_NODE: inherited, it made the
117
+ // "launch" start a Node REPL that exits instead of PandaStudio.
118
+ const child = spawn(bin, [], { detached: true, stdio: "ignore", env: appLaunchEnv() });
170
119
  child.unref();
171
120
  } catch (err) {
172
121
  throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
@@ -176,7 +125,7 @@ async function autoLaunchAndWait(timeoutSec = 60) {
176
125
  while (Date.now() < deadline) {
177
126
  try {
178
127
  const c = await readCredentials();
179
- if (await probeHealth(c.port, 2000)) return c;
128
+ if ((await probeHealth(c.port, 2000)).state === "up") return c;
180
129
  } catch {
181
130
  /* still booting */
182
131
  }
@@ -211,18 +160,50 @@ function httpRequestLocal(port, pathSuffix, { method = "GET", headers = {}, body
211
160
  });
212
161
  }
213
162
 
214
- async function callPandastudio(command, args = {}) {
215
- const creds = await autoLaunchAndWait();
216
- const res = await httpRequestLocal(creds.port, "/v1/call", {
163
+ async function postCall(creds, command, args) {
164
+ return httpRequestLocal(creds.port, "/v1/call", {
217
165
  method: "POST",
218
166
  headers: {
219
167
  "Content-Type": "application/json",
220
168
  Authorization: `Bearer ${creds.token}`,
221
169
  // Set by the app when it runs this server for its own chat panel.
222
- ...(process.env.PANDASTUDIO_CALLER === "in-app" ? { "X-PandaStudio-Caller": "in-app" } : {}),
170
+ ...(IN_APP ? { "X-PandaStudio-Caller": "in-app" } : {}),
223
171
  },
224
172
  body: JSON.stringify({ command, args }),
225
173
  });
174
+ }
175
+
176
+ async function callPandastudio(command, args = {}) {
177
+ // In-app: the app is running (it spawned us). Never launch it, and fail in
178
+ // seconds with the real reason instead of outliving the client's timeout.
179
+ let creds = IN_APP ? await connectInApp() : await autoLaunchAndWait();
180
+ let res;
181
+ try {
182
+ res = await postCall(creds, command, args);
183
+ } catch (err) {
184
+ if (!IN_APP) throw err;
185
+ throw new Error(
186
+ `${IN_APP_UNAVAILABLE}: the call to 127.0.0.1:${creds.port} failed (${err?.code ?? err?.message ?? err}).`,
187
+ );
188
+ }
189
+ if (IN_APP && res.status === 401) {
190
+ // The app restarted its automation server (new token) after it
191
+ // spawned us. The files hold the current one.
192
+ try {
193
+ const fresh = await fileCredentials(configDir());
194
+ if (fresh.token !== creds.token || fresh.port !== creds.port) {
195
+ creds = fresh;
196
+ res = await postCall(creds, command, args);
197
+ }
198
+ } catch {
199
+ /* reported below */
200
+ }
201
+ if (res.status === 401) {
202
+ throw new Error(
203
+ `${IN_APP_UNAVAILABLE}: PandaStudio rejected the tools' access token (its automation server restarted).`,
204
+ );
205
+ }
206
+ }
226
207
  const text = res.text;
227
208
  let body;
228
209
  try {
@@ -565,7 +546,7 @@ const ASSETS_PROP = {
565
546
  };
566
547
  const ANCHOR_PROP = {
567
548
  type: "number",
568
- description: "SOURCE ms (a transcript word's startMs) to pin to, surviving cuts.",
549
+ description: "SOURCE ms of a transcript word; survives cuts.",
569
550
  };
570
551
  const ANCHOR_END_PROP = { type: "number", description: "Anchor end, SOURCE ms." };
571
552
  const KEYFRAME_TARGET_DOC =
@@ -1347,6 +1328,11 @@ const TOOLS = [
1347
1328
  slots: { type: "object", description: "Templates: changed slots only." },
1348
1329
  background: { type: "string", enum: ["solid", "transparent", "glass"] },
1349
1330
  html: { type: "string", description: "HTML graphics: full replacement." },
1331
+ durationMs: { type: "number", description: "text-behind length." },
1332
+ templateId: {
1333
+ type: "string",
1334
+ description: "Switch template (e.g. a retired one's replacement); slots map across.",
1335
+ },
1350
1336
  expectedRevision: { type: "number" },
1351
1337
  },
1352
1338
  required: ["overlayId"],
@@ -1545,7 +1531,7 @@ const TOOLS = [
1545
1531
  {
1546
1532
  name: "project_add_lower_third",
1547
1533
  description:
1548
- "Render an lt-* nameplate and place it at atMs in one call (designs: motion_list category lower-third). ASYNC: { jobId }; job_wait gives result.overlayId once placed.",
1534
+ "Render a nameplate and place it at atMs in one call, laid out for the project's aspect. ASYNC: { jobId }; job_wait gives result.overlayId once placed.",
1549
1535
  inputSchema: {
1550
1536
  type: "object",
1551
1537
  properties: {
@@ -1553,12 +1539,16 @@ const TOOLS = [
1553
1539
  path: { type: "string" },
1554
1540
  name: { type: "string" },
1555
1541
  title: { type: "string", description: "Second line (role / handle)." },
1556
- templateId: { type: "string", description: "Default lt-vox-marker." },
1542
+ templateId: {
1543
+ type: "string",
1544
+ description:
1545
+ "lt-vox-marker (default, bold marker) | lt-glass-card | lt-minimal-line | lt-bold-bar | lt-logo-name (slots.logo) | lt-duo (slots.name2/title2).",
1546
+ },
1557
1547
  atMs: { type: "number" },
1558
1548
  aspectRatio: {
1559
1549
  type: "string",
1560
1550
  enum: ["16:9", "9:16", "1:1"],
1561
- description: "Match the project.",
1551
+ description: "Default: the project's aspect.",
1562
1552
  },
1563
1553
  slots: { type: "object", description: "Extra slot values (motion_list)." },
1564
1554
  soundUrl: { type: "string", description: 'Default bundled:sound/mouse-click; "none".' },
@@ -1575,6 +1565,29 @@ const TOOLS = [
1575
1565
  },
1576
1566
  command: "project.add-lower-third",
1577
1567
  },
1568
+ {
1569
+ name: "project_add_title_behind",
1570
+ description:
1571
+ "1-3 huge words at head height BEHIND the presenter. ASYNC: job_wait gives regionId, face.",
1572
+ inputSchema: {
1573
+ type: "object",
1574
+ properties: {
1575
+ id: { type: "string" },
1576
+ path: { type: "string" },
1577
+ text: { type: "string" },
1578
+ atMs: { type: "number" },
1579
+ style: { type: "string", enum: ["3d", "bold", "serif"] },
1580
+ durationMs: { type: "number", description: "Default 3000." },
1581
+ accentColor: { type: "string" },
1582
+ position: { type: "string", enum: ["auto", "top", "center"] },
1583
+ animation: { type: "string", enum: ["in-out", "in", "none"] },
1584
+ behind: { type: "boolean", description: "false: in front (no face)." },
1585
+ anchorSourceMs: ANCHOR_PROP,
1586
+ },
1587
+ required: ["text", "atMs"],
1588
+ },
1589
+ command: "project.add-title-behind",
1590
+ },
1578
1591
 
1579
1592
  // ── compose: visual regions ─────────────────────────────────────
1580
1593
  {
@@ -2062,7 +2075,7 @@ const TOOLS = [
2062
2075
  {
2063
2076
  name: "project_render_frame",
2064
2077
  description:
2065
- "Render the composited frame at an edited time to a PNG (matches export). Returns { path, timeMs, maskRect (video rect in 0-1 image fractions, for mapping boxes to spotlight coords), warnings? } (tell the user warnings).",
2078
+ "Render the composited frame at an edited time to a PNG (matches export). Returns { path, timeMs (snapped to the export's frame grid, fps), maskRect (video rect in 0-1 image fractions, for mapping boxes to spotlight coords), warnings? } (tell the user warnings).",
2066
2079
  inputSchema: {
2067
2080
  type: "object",
2068
2081
  properties: {
@@ -2792,6 +2805,10 @@ const TOOLS = [
2792
2805
  type: ["boolean", "string"],
2793
2806
  description: "streaming / true (-14 LUFS, default), podcast (-16), off / false.",
2794
2807
  },
2808
+ frameRate: {
2809
+ type: ["number", "string"],
2810
+ description: "auto (default) | 30 | 60 | source (fastest source).",
2811
+ },
2795
2812
  expectedRevision: { type: "number" },
2796
2813
  },
2797
2814
  },
@@ -2893,6 +2910,47 @@ const TOOLS = [
2893
2910
  },
2894
2911
  command: "project.add-audio",
2895
2912
  },
2913
+ {
2914
+ name: "project_add_sound_cues",
2915
+ description:
2916
+ "Place many timed SFX in one write (one undo). Each cue = an audio overlay. Same group again REPLACES its cues ([] clears). Returns overlayIds + warnings.",
2917
+ inputSchema: {
2918
+ type: "object",
2919
+ properties: {
2920
+ id: { type: "string" },
2921
+ path: { type: "string" },
2922
+ cues: {
2923
+ type: "array",
2924
+ maxItems: 500,
2925
+ items: {
2926
+ type: "object",
2927
+ properties: {
2928
+ sound: {
2929
+ type: "string",
2930
+ description: "Bundled id or absolute path.",
2931
+ },
2932
+ atMs: { type: "number", description: "Edited-timeline ms." },
2933
+ volume: { type: "number", description: "0-2." },
2934
+ durationMs: { type: "number" },
2935
+ sourceStartMs: { type: "number" },
2936
+ fadeInMs: { type: "number" },
2937
+ fadeOutMs: { type: "number" },
2938
+ anchorSourceMs: {
2939
+ type: "number",
2940
+ description: "SOURCE ms word anchor; omit for graphics.",
2941
+ },
2942
+ },
2943
+ required: ["sound", "atMs"],
2944
+ },
2945
+ },
2946
+ group: { type: "string", description: 'e.g. "sfx".' },
2947
+ volume: { type: "number", description: "Cue default, 0.8." },
2948
+ expectedRevision: { type: "number" },
2949
+ },
2950
+ required: ["cues"],
2951
+ },
2952
+ command: "project.add-sound-cues",
2953
+ },
2896
2954
  {
2897
2955
  name: "project_remove_audio",
2898
2956
  description:
@@ -3654,6 +3712,28 @@ const TOOLS = [
3654
3712
  },
3655
3713
  command: "media.import",
3656
3714
  },
3715
+ {
3716
+ name: "media_download_url",
3717
+ description:
3718
+ "Download a video or its audio from a page link (YouTube, Vimeo...). Only content the user has rights to. ASYNC: job_wait gives { path, title, durationMs }.",
3719
+ inputSchema: {
3720
+ type: "object",
3721
+ properties: {
3722
+ url: { type: "string" },
3723
+ format: { type: "string", enum: ["video", "audio"] },
3724
+ maxHeight: { type: "number", description: "Default 1080." },
3725
+ startMs: { type: "number" },
3726
+ endMs: { type: "number" },
3727
+ maxMinutes: { type: "number", description: "Default 120." },
3728
+ name: { type: "string" },
3729
+ addToProject: { type: "string", description: "Project id." },
3730
+ as: { type: "string", enum: ["clip", "overlay", "audio"] },
3731
+ atMs: { type: "number" },
3732
+ },
3733
+ required: ["url"],
3734
+ },
3735
+ command: "media.download-url",
3736
+ },
3657
3737
  {
3658
3738
  name: "media_generate_sound_effect",
3659
3739
  description:
@@ -3802,8 +3882,22 @@ const TOOLS = [
3802
3882
  {
3803
3883
  name: "motion_list",
3804
3884
  description:
3805
- "List motion-graphic templates (slots; render with motion_generate) and slot-less registryBlocks (edit htmlPath, render with motion_render_html).",
3806
- inputSchema: { type: "object", properties: {} },
3885
+ "List motion-graphic templates (slots, family, tags; render with motion_generate) and slot-less registryBlocks (edit htmlPath, render with motion_render_html). Retired templates are hidden unless includeRetired.",
3886
+ inputSchema: {
3887
+ type: "object",
3888
+ properties: {
3889
+ family: {
3890
+ type: "string",
3891
+ description:
3892
+ "titles|text-behind|captions|callouts|lists-steps|stats-data|comparisons|product|panels|social-proof|social|intro-outro|end-cards|lower-thirds (comma-separated ok).",
3893
+ },
3894
+ tags: { type: "string", description: "Any of these tags, comma-separated." },
3895
+ query: { type: "string", description: "Search, e.g. 'subscribe', 'bar chart'." },
3896
+ aspect: { type: "string", enum: ["16:9", "9:16", "1:1"] },
3897
+ includeRetired: { type: "boolean" },
3898
+ includeBlocks: { type: "boolean", description: "Default true." },
3899
+ },
3900
+ },
3807
3901
  command: "motion.list",
3808
3902
  },
3809
3903
  {
@@ -3949,12 +4043,14 @@ const TOOLS = [
3949
4043
  {
3950
4044
  name: "motion_screenshot",
3951
4045
  description:
3952
- "Capture one PNG frame of an HTML composition, as motion_render_html would. Returns { outputPath, previewPath } (inspect previewPath).",
4046
+ "Capture one PNG frame of an HTML composition (or a template with slots), as motion_render_html / motion_generate would. Returns { outputPath, previewPath } (inspect previewPath).",
3953
4047
  inputSchema: {
3954
4048
  type: "object",
3955
4049
  properties: {
3956
- html: { type: "string", description: "Or htmlPath." },
4050
+ html: { type: "string", description: "Or htmlPath, or templateId." },
3957
4051
  htmlPath: { type: "string" },
4052
+ templateId: { type: "string", description: "Template id; fill with slots." },
4053
+ slots: { type: "object" },
3958
4054
  aspectRatio: { type: "string", enum: ["16:9", "9:16", "1:1"] },
3959
4055
  width: { type: "number" },
3960
4056
  height: { type: "number" },
@@ -4005,8 +4101,23 @@ const TOOLS = [
4005
4101
  },
4006
4102
  {
4007
4103
  name: "asset_list_sounds",
4008
- description: "List bundled sound effects (id, name, category, path).",
4009
- inputSchema: { type: "object", properties: {} },
4104
+ description:
4105
+ "List bundled SFX (~190: id, category, tags, durationMs, variantGroup, path). Filter; summary=true gives categories + groups only.",
4106
+ inputSchema: {
4107
+ type: "object",
4108
+ properties: {
4109
+ category: {
4110
+ type: "string",
4111
+ description:
4112
+ "ui|notification|motion|digital|impact|typing|outcome|ambience, comma-separated.",
4113
+ },
4114
+ tag: { type: "string", description: 'e.g. "click,soft" (all match).' },
4115
+ mood: { type: "string" },
4116
+ group: { type: "string", description: "Variant group." },
4117
+ query: { type: "string" },
4118
+ summary: { type: "boolean" },
4119
+ },
4120
+ },
4010
4121
  command: "asset.list-sounds",
4011
4122
  },
4012
4123
  {
@@ -4105,6 +4216,10 @@ const TOOLS = [
4105
4216
  type: ["boolean", "string"],
4106
4217
  description: "streaming / podcast / off; omit = project setting.",
4107
4218
  },
4219
+ frameRate: {
4220
+ type: ["number", "string"],
4221
+ description: "30 | 60 | source | auto; omit = project setting.",
4222
+ },
4108
4223
  },
4109
4224
  },
4110
4225
  command: "export.start",
@@ -4478,7 +4593,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
4478
4593
  };
4479
4594
  });
4480
4595
 
4481
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
4596
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
4482
4597
  const tool = TOOLS.find((t) => t.name === request.params.name);
4483
4598
  if (!tool) {
4484
4599
  return {
@@ -4509,7 +4624,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4509
4624
  }
4510
4625
 
4511
4626
  try {
4512
- const result = await callPandastudio(command, dispatchArgs);
4627
+ const result = await withProgressHeartbeat(request, extra, () =>
4628
+ callPandastudio(command, dispatchArgs),
4629
+ );
4513
4630
  // Format the response for the MCP client. Default is a text
4514
4631
  // block carrying the JSON result. For `motion.screenshot` and
4515
4632
  // `motion.verify-frames` we ALSO inline the downscaled preview
@@ -4559,6 +4676,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4559
4676
  }
4560
4677
  });
4561
4678
 
4679
+ /**
4680
+ * Keep a long call alive on the client side. MCP clients time a request out
4681
+ * after 60 s unless progress arrives (opencode, the in-app agent's runtime,
4682
+ * sends every tool call with a progress token and resets its timer on each
4683
+ * notification). job.wait, renders and presenter takes run for minutes, so
4684
+ * while the app works we report progress every 15 s. Clients that sent no
4685
+ * progress token get nothing.
4686
+ */
4687
+ const HEARTBEAT_MS = Number(process.env.PANDASTUDIO_MCP_PROGRESS_MS) || 15_000;
4688
+
4689
+ async function withProgressHeartbeat(request, extra, run) {
4690
+ const progressToken = request.params?._meta?.progressToken;
4691
+ if (progressToken === undefined || typeof extra?.sendNotification !== "function") return run();
4692
+ let beats = 0;
4693
+ const timer = setInterval(() => {
4694
+ beats += 1;
4695
+ extra
4696
+ .sendNotification({
4697
+ method: "notifications/progress",
4698
+ params: { progressToken, progress: beats, message: "PandaStudio is working" },
4699
+ })
4700
+ .catch(() => undefined);
4701
+ }, HEARTBEAT_MS);
4702
+ try {
4703
+ return await run();
4704
+ } finally {
4705
+ clearInterval(timer);
4706
+ }
4707
+ }
4708
+
4562
4709
  /** Decide which (if any) preview PNGs to inline as image content
4563
4710
  * blocks for a given tool result. Only motion.screenshot (one
4564
4711
  * preview) and motion.verify-frames (one per frame) qualify today.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writepanda/mcp",
3
- "version": "1.180.0",
3
+ "version": "1.183.0",
4
4
  "description": "Model Context Protocol server for PandaStudio. Exposes the desktop video editor's automation surface to Cursor, Continue, Cline, Claude Desktop, and any MCP-compliant client.",
5
5
  "keywords": [
6
6
  "pandastudio",