@writepanda/mcp 1.221.0 → 1.222.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.
@@ -144,21 +144,30 @@ export async function connectInApp({
144
144
  busyWaitMs = 20_000,
145
145
  probe = probeHealth,
146
146
  readFiles = fileCredentials,
147
+ excludePort = null,
147
148
  } = {}) {
148
149
  const attempts = [];
149
150
  const candidates = [];
150
151
  const fromEnv = envCredentials(env);
151
- if (fromEnv) candidates.push(fromEnv);
152
+ if (fromEnv && fromEnv.port !== excludePort) candidates.push(fromEnv);
152
153
  let fileError = null;
153
154
  try {
154
155
  const fromFiles = await readFiles(dir);
155
- if (!fromEnv || fromFiles.port !== fromEnv.port || fromFiles.token !== fromEnv.token) {
156
+ if (
157
+ fromFiles.port !== excludePort &&
158
+ (!fromEnv || fromFiles.port !== fromEnv.port || fromFiles.token !== fromEnv.token)
159
+ ) {
156
160
  candidates.push(fromFiles);
157
161
  }
158
162
  } catch (err) {
159
163
  fileError = err?.message ?? String(err);
160
164
  }
161
165
  if (candidates.length === 0) {
166
+ if (excludePort !== null) {
167
+ throw unavailable(
168
+ `nothing is listening on 127.0.0.1:${excludePort} any more and PandaStudio hasn't advertised another address (${fileError ?? `${dir} still names that port`}).`,
169
+ );
170
+ }
162
171
  throw unavailable(
163
172
  `PandaStudio didn't pass its automation address to the tools and ${fileError ?? "the token/port files are missing"}.`,
164
173
  );
@@ -175,6 +184,37 @@ export async function connectInApp({
175
184
  throw unavailable(`PandaStudio's automation server didn't answer. ${attempts.join("; ")}.`);
176
185
  }
177
186
 
187
+ /** A socket error meaning nothing accepted the connection: the request never
188
+ * reached the app, so resending it can't run a command twice. */
189
+ export function isConnectionRefused(err) {
190
+ const code = err?.code ?? err?.cause?.code;
191
+ return code === "ECONNREFUSED" || code === "EADDRNOTAVAIL";
192
+ }
193
+
194
+ /**
195
+ * Send `call(creds)`. When the connection is REFUSED, the port we hold is
196
+ * stale: the app restarted its automation server on another port (a fallback
197
+ * from a busy preferred port, a Settings change, a relaunch) after the
198
+ * credentials were captured, e.g. in the environment of a long-lived agent
199
+ * process. Re-resolve once with `reresolve(staleCreds)` (which re-reads the
200
+ * token/port files) and resend to the new address. Only a refused connection
201
+ * is retried; any other failure may have reached the app.
202
+ *
203
+ * Returns { creds, res } with the credentials that worked. If re-resolving
204
+ * fails, its error (which says why) is thrown; if it yields the same address,
205
+ * the original error is.
206
+ */
207
+ export async function callWithReconnect(creds, call, reresolve) {
208
+ try {
209
+ return { creds, res: await call(creds) };
210
+ } catch (err) {
211
+ if (!isConnectionRefused(err)) throw err;
212
+ const fresh = await reresolve(creds);
213
+ if (!fresh || (fresh.port === creds.port && fresh.token === creds.token)) throw err;
214
+ return { creds: fresh, res: await call(fresh) };
215
+ }
216
+ }
217
+
178
218
  /**
179
219
  * Environment for LAUNCHING the desktop app. This server runs as Electron in
180
220
  * node mode (ELECTRON_RUN_AS_NODE=1), and a child inherits that: spawning
package/bin/server.mjs CHANGED
@@ -38,6 +38,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
38
38
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
39
39
  import {
40
40
  appLaunchEnv,
41
+ callWithReconnect,
41
42
  cleanFileValue,
42
43
  configDir,
43
44
  connectInApp,
@@ -116,7 +117,13 @@ async function autoLaunchAndWait(timeoutSec = 60) {
116
117
  try {
117
118
  // appLaunchEnv drops ELECTRON_RUN_AS_NODE: inherited, it made the
118
119
  // "launch" start a Node REPL that exits instead of PandaStudio.
119
- const child = spawn(bin, [], { detached: true, stdio: "ignore", env: appLaunchEnv() });
120
+ // windowsHide: false: this is the desktop app, whose window must show.
121
+ const child = spawn(bin, [], {
122
+ detached: true,
123
+ stdio: "ignore",
124
+ env: appLaunchEnv(),
125
+ windowsHide: false,
126
+ });
120
127
  child.unref();
121
128
  } catch (err) {
122
129
  throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
@@ -180,9 +187,16 @@ async function callPandastudio(command, args = {}) {
180
187
  let creds = IN_APP ? await connectInApp() : await autoLaunchAndWait();
181
188
  let res;
182
189
  try {
183
- res = await postCall(creds, command, args);
190
+ // A refused connection means the address went stale between the
191
+ // health probe and the call (the app moved its automation server):
192
+ // re-read where it is now and resend once.
193
+ ({ creds, res } = await callWithReconnect(
194
+ creds,
195
+ (c) => postCall(c, command, args),
196
+ (stale) => (IN_APP ? connectInApp({ excludePort: stale.port }) : autoLaunchAndWait()),
197
+ ));
184
198
  } catch (err) {
185
- if (!IN_APP) throw err;
199
+ if (!IN_APP || err?.inAppUnavailable) throw err;
186
200
  throw new Error(
187
201
  `${IN_APP_UNAVAILABLE}: the call to 127.0.0.1:${creds.port} failed (${err?.code ?? err?.message ?? err}).`,
188
202
  );
@@ -3332,7 +3346,7 @@ const TOOLS = [
3332
3346
  {
3333
3347
  name: "project_update_region",
3334
3348
  description:
3335
- "Patch a region; unspecified fields are kept. Patchable: zoom (depth, focusX, focusY, focusMode static|auto), trim / speed (startMs, endMs, speed, rampIn, rampOut, preservePitch, freezeMs, reverse, reverseAudio), fx (atMs, durationMs, opacity, blendMode, speed, zIndex), annotation (zIndex), overlay (x, y, width, height in canvas %, layer, opacity, blendMode, muted, volume, loop, motionBlur, zIndex), adjustment (startMs, endMs, blendMode), motion (motionBlur), clip-transform (startMs, endMs, preset, transitionMs, easing, bezier), background-effect (as project_add_background_effect), audio-overlay (startMs, endMs, sourceStartMs, volume 0-2, fadeIn, fadeOut).",
3349
+ "Patch a region; unspecified fields are kept. Patchable: zoom (depth, focusX, focusY, focusMode static|auto), trim / speed (startMs, endMs, speed, rampIn, rampOut, preservePitch, freezeMs, reverse, reverseAudio), fx (startMs, endMs, opacity, blendMode, speed, zIndex), annotation (startMs, endMs, text = the words shown (content / textContent also work), x, y, width, height, the style fields below, zIndex), overlay (x, y, width, height in canvas %, layer, opacity, blendMode, muted, volume, loop, motionBlur, zIndex), adjustment (startMs, endMs, blendMode), motion (motionBlur), clip-transform (startMs, endMs, preset, transitionMs, easing, bezier), background-effect (as project_add_background_effect), audio-overlay (startMs, endMs, sourceStartMs, volume 0-2, fadeIn, fadeOut). A key the region type doesn't have is not applied: the result lists it in ignoredKeys with a warning, and the call fails when nothing in the patch applies.",
3336
3350
  inputSchema: {
3337
3351
  type: "object",
3338
3352
  properties: {
@@ -5689,7 +5703,7 @@ const TOOLS = [
5689
5703
  {
5690
5704
  name: "asset_list_fonts",
5691
5705
  description:
5692
- "Font families text can use: bundled, scriptFallbacks (Noto, used per character for Indic/Arabic/Hebrew/Thai text), custom (the user's imported fonts; prefer one covering the video's language), system with includeSystem. Pass one to caption_set_style fontFamily.",
5706
+ "Font families text can use (a missing one: asset_import_font): bundled, scriptFallbacks (Noto, used per character for Indic/Arabic/Hebrew/Thai text), custom (the user's imported fonts; prefer one covering the video's language), system with includeSystem. Pass one to caption_set_style fontFamily.",
5693
5707
  inputSchema: {
5694
5708
  type: "object",
5695
5709
  properties: {
@@ -5698,6 +5712,23 @@ const TOOLS = [
5698
5712
  },
5699
5713
  command: "asset.list-fonts",
5700
5714
  },
5715
+ {
5716
+ name: "asset_import_font",
5717
+ description:
5718
+ 'Import a font like the font picker does, so captions, annotations and motion elements can use it: a local file (path: .ttf/.otf/.woff/.woff2, 30 MB max) or a Google Fonts family (google: "Montserrat", downloaded now). Only fonts the user owns or openly licensed ones. Returns { family } to pass as fontFamily; an already imported family is reused.',
5719
+ inputSchema: {
5720
+ type: "object",
5721
+ properties: {
5722
+ path: { type: "string", description: "Absolute font file path. Give path or google." },
5723
+ google: { type: "string", description: "Google Fonts family name. Give path or google." },
5724
+ family: {
5725
+ type: "string",
5726
+ description: "path only: name to register it under (default: the font's own family).",
5727
+ },
5728
+ },
5729
+ },
5730
+ command: "asset.import-font",
5731
+ },
5701
5732
 
5702
5733
  // ── project-aware LLM ──────────────────────────────────────────
5703
5734
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writepanda/mcp",
3
- "version": "1.221.0",
3
+ "version": "1.222.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",