@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.
- package/bin/appConnection.mjs +201 -0
- package/bin/server.mjs +236 -89
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
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
|
-
|
|
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
|
|
215
|
-
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
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: {
|
|
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: "
|
|
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: {
|
|
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:
|
|
4009
|
-
|
|
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
|
|
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.
|
|
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",
|