@writepanda/mcp 1.9.3

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/bin/server.mjs +755 -0
  4. package/package.json +45 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kamala Kannan Sankarraj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # @writepanda/mcp
2
+
3
+ [Model Context Protocol](https://modelcontextprotocol.io) server for [PandaStudio](https://www.writepanda.ai) — a desktop video editor for YouTube creators. Lets AI agents edit videos like a human does: transcribe, delete filler words, drop motion graphics / FX / lower-thirds, generate captions, render the final MP4 — all without the user leaving their chat.
4
+
5
+ Works with **Claude Desktop, Cursor, Continue.dev, Cline, and any MCP-compliant client**.
6
+
7
+ > **You also need PandaStudio installed.** The MCP server is a thin translator between MCP and PandaStudio's localhost-only automation API. Get the desktop app at [writepanda.ai](https://www.writepanda.ai).
8
+
9
+ ## Install
10
+
11
+ The MCP server runs on demand via `npx` — no global install needed. Add to your client config:
12
+
13
+ ### Claude Desktop
14
+
15
+ `~/Library/Application Support/Claude/claude_desktop_config.json` (Mac) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "pandastudio": {
21
+ "command": "npx",
22
+ "args": ["-y", "@writepanda/mcp"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ ### Cursor
29
+
30
+ `.cursor/mcp.json` in your workspace:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "pandastudio": {
36
+ "command": "npx",
37
+ "args": ["-y", "@writepanda/mcp"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ### Continue.dev / Cline
44
+
45
+ Same shape, in their respective MCP config files.
46
+
47
+ After adding, restart your client. The MCP server auto-launches PandaStudio if it isn't running and waits for the localhost HTTP server to come up (~5s).
48
+
49
+ ## What the agent gets
50
+
51
+ 30+ tools covering the full PandaStudio editorial surface:
52
+
53
+ | Category | Tools |
54
+ |---|---|
55
+ | Discovery | `system_status`, `system_list_commands` |
56
+ | Project lifecycle | `project_list`, `project_show`, `project_read`, `project_new`, `project_open` |
57
+ | Composition | `project_add_clip`, `project_add_motion_graphic`, `project_add_fx`, `project_add_lower_third`, `project_add_zoom`, `project_add_trim`, `project_add_speed` |
58
+ | Transcript editing | `transcript_transcribe`, `transcript_get`, `transcript_delete_words`, `transcript_remove_fillers`, `transcript_search` |
59
+ | Audio | `audio_clean` (DeepFilter denoising) |
60
+ | Captions | `caption_toggle`, `caption_set_template` |
61
+ | Motion graphics | `motion_list`, `motion_themes`, `motion_generate` |
62
+ | Assets | `asset_list_sounds`, `asset_list_fx` |
63
+ | AI metadata | `llm_generate_title`, `llm_generate_description`, `llm_generate_timestamps` |
64
+ | Export | `export_start`, `export_list` |
65
+ | Preview overlay | `preview_show`, `preview_seek`, `preview_hide` |
66
+ | Async jobs | `job_wait`, `job_get` |
67
+ | Escape hatch | `pandastudio_call` (raw verb.noun dispatch for anything not in the static list) |
68
+
69
+ ## Idiomatic agent prompt
70
+
71
+ ```
72
+ Edit the two videos in /Users/me/Downloads/raw-clips/ — transcribe them,
73
+ remove filler words, add a "How I Built This" intro card, enable bold-yellow
74
+ captions, and export the final MP4. Show me the preview overlay so I can
75
+ watch as you work.
76
+ ```
77
+
78
+ The agent calls `project_new`, `transcript_transcribe`, `transcript_remove_fillers`, `motion_generate`, `project_add_motion_graphic`, `caption_toggle`, `caption_set_template`, `preview_show`, then `export_start`. ~12 tool calls, fully unattended.
79
+
80
+ ## How it works
81
+
82
+ ```
83
+ MCP client (Cursor / Continue / Cline / Claude Desktop)
84
+ ↓ JSON-RPC over stdio
85
+ @writepanda/mcp (this server)
86
+ ↓ HTTP POST 127.0.0.1:7878/v1/call
87
+ PandaStudio desktop app
88
+ ↓ in-process function calls
89
+ Editorial primitives (transcript, motion graphics, export, ...)
90
+ ```
91
+
92
+ Every call is bearer-authenticated against the per-launch token PandaStudio writes to `~/.config/pandastudio/token` (Mac/Linux) or `%APPDATA%\pandastudio\token` (Windows). Loopback-only — nothing leaves your machine.
93
+
94
+ ## Licensing
95
+
96
+ The MCP server honours the same license gate the desktop app does. With an expired trial and no license, only diagnostic tools work and every editorial tool returns `{ ok: false, details: { code: "trial_expired" } }`. Activate a license in the desktop app's Settings → License panel.
97
+
98
+ ## Companions
99
+
100
+ - **[`@writepanda/cli`](https://www.npmjs.com/package/@writepanda/cli)** — same surface as a shell command (`pandastudio system.status`). Use this when scripting from bash / CI.
101
+ - **Bundled Claude Skill** — auto-loaded markdown instructions for Claude Code / Claude Desktop. Install via PandaStudio Settings → Local automation → Install Skill (drops into `~/.claude/skills/pandastudio/`).
102
+
103
+ ## Documentation
104
+
105
+ - Full surface + recipes: [writepanda.ai/cli](https://www.writepanda.ai/cli)
106
+ - Source: [github.com/kamskans/openscreen](https://github.com/kamskans/openscreen)
107
+
108
+ ## License
109
+
110
+ MIT.
package/bin/server.mjs ADDED
@@ -0,0 +1,755 @@
1
+ #!/usr/bin/env node
2
+ // PandaStudio MCP server — translates Model Context Protocol tool
3
+ // calls into HTTP requests against the desktop app's localhost
4
+ // automation API.
5
+ //
6
+ // Architecture:
7
+ //
8
+ // MCP client (Cursor / Continue / Cline / Claude Desktop)
9
+ // ↓ JSON-RPC over stdio (MCP protocol)
10
+ // this server (writepanda-mcp)
11
+ // ↓ HTTP POST localhost:7878/v1/call
12
+ // PandaStudio desktop app's automation server
13
+ // ↓ in-process function calls
14
+ // Editorial primitives (transcript, motion graphics, export, ...)
15
+ //
16
+ // Tool exposure strategy:
17
+ // - 30+ STATIC tools mirror the high-value verbs (every editorial
18
+ // primitive an agent needs for the canonical workflow). Static
19
+ // because MCP clients cache tool schemas at connect time and
20
+ // don't re-fetch them mid-session — a dynamic registry would go
21
+ // stale in the client cache.
22
+ // - One ESCAPE HATCH tool: `pandastudio_call` takes
23
+ // `{ command: "verb.noun", args: {...} }` so agents can hit any
24
+ // verb that lands in PandaStudio after this MCP version shipped.
25
+ // Long tail covered without a re-publish.
26
+ //
27
+ // Naming convention: MCP tool names use underscores (snake_case);
28
+ // PandaStudio CLI uses verb.noun. Translation is verb_noun ↔ verb.noun.
29
+
30
+ import { spawn } from "node:child_process";
31
+ import fs from "node:fs/promises";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import process from "node:process";
35
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
36
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
37
+ import {
38
+ CallToolRequestSchema,
39
+ ListToolsRequestSchema,
40
+ } from "@modelcontextprotocol/sdk/types.js";
41
+
42
+ // ── Credentials + transport ───────────────────────────────────────────
43
+
44
+ function configDir() {
45
+ if (process.env.PANDASTUDIO_CONFIG_DIR) return process.env.PANDASTUDIO_CONFIG_DIR;
46
+ if (process.platform === "win32") {
47
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
48
+ return path.join(appData, "pandastudio");
49
+ }
50
+ return path.join(os.homedir(), ".config", "pandastudio");
51
+ }
52
+
53
+ const TOKEN_FILE = path.join(configDir(), "token");
54
+ const PORT_FILE = path.join(configDir(), "port");
55
+
56
+ async function readCredentials() {
57
+ const port = Number((await fs.readFile(PORT_FILE, "utf-8")).trim());
58
+ const token = (await fs.readFile(TOKEN_FILE, "utf-8")).trim();
59
+ if (!Number.isInteger(port) || port <= 0) throw new Error(`invalid port in ${PORT_FILE}`);
60
+ if (!token) throw new Error(`empty token in ${TOKEN_FILE}`);
61
+ return { port, token };
62
+ }
63
+
64
+ async function probeHealth(port, timeoutMs = 1500) {
65
+ const ctrl = new AbortController();
66
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
67
+ try {
68
+ const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
69
+ clearTimeout(t);
70
+ return res.ok;
71
+ } catch {
72
+ clearTimeout(t);
73
+ return false;
74
+ }
75
+ }
76
+
77
+ function findInstalledApp() {
78
+ if (process.env.PANDASTUDIO_BIN) return process.env.PANDASTUDIO_BIN;
79
+ if (process.platform === "darwin") {
80
+ return "/Applications/PandaStudio.app/Contents/MacOS/PandaStudio";
81
+ }
82
+ if (process.platform === "win32") {
83
+ const programFiles = process.env["ProgramFiles"] ?? "C:\\Program Files";
84
+ return path.join(programFiles, "PandaStudio", "PandaStudio.exe");
85
+ }
86
+ return "/opt/PandaStudio/pandastudio";
87
+ }
88
+
89
+ async function autoLaunchAndWait(timeoutSec = 60) {
90
+ // Try a fast probe in case the user already has it running.
91
+ try {
92
+ const c = await readCredentials();
93
+ if (await probeHealth(c.port)) return c;
94
+ } catch {
95
+ /* not running */
96
+ }
97
+
98
+ const bin = findInstalledApp();
99
+ try {
100
+ const child = spawn(bin, [], { detached: true, stdio: "ignore", env: process.env });
101
+ child.unref();
102
+ } catch (err) {
103
+ throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
104
+ }
105
+
106
+ const deadline = Date.now() + timeoutSec * 1000;
107
+ while (Date.now() < deadline) {
108
+ try {
109
+ const c = await readCredentials();
110
+ if (await probeHealth(c.port, 2000)) return c;
111
+ } catch {
112
+ /* still booting */
113
+ }
114
+ await new Promise((r) => setTimeout(r, 500));
115
+ }
116
+ throw new Error(
117
+ `PandaStudio did not become ready within ${timeoutSec}s. Open the app and enable "Allow local automation" in Settings.`,
118
+ );
119
+ }
120
+
121
+ async function callPandastudio(command, args = {}) {
122
+ const creds = await autoLaunchAndWait();
123
+ const res = await fetch(`http://127.0.0.1:${creds.port}/v1/call`, {
124
+ method: "POST",
125
+ headers: {
126
+ "Content-Type": "application/json",
127
+ Authorization: `Bearer ${creds.token}`,
128
+ },
129
+ body: JSON.stringify({ command, args }),
130
+ });
131
+ const text = await res.text();
132
+ let body;
133
+ try {
134
+ body = text.length === 0 ? null : JSON.parse(text);
135
+ } catch {
136
+ body = { raw: text };
137
+ }
138
+ if (res.status >= 400) {
139
+ throw new Error(`HTTP ${res.status}: ${body?.error ?? text.slice(0, 200)}`);
140
+ }
141
+ return body;
142
+ }
143
+
144
+ // ── Tool catalogue ────────────────────────────────────────────────────
145
+ //
146
+ // Static MCP tool schemas. Each tool name uses snake_case (MCP
147
+ // convention); the `command` field is the underlying verb.noun the
148
+ // HTTP server expects. Keep this list focused on the editorial
149
+ // workflow — the `pandastudio_call` escape hatch covers the long
150
+ // tail and any post-publish additions.
151
+
152
+ const TOOLS = [
153
+ // ── system + discovery ──────────────────────────────────────────
154
+ {
155
+ name: "system_status",
156
+ description:
157
+ "Probe PandaStudio's automation server. Returns app version, license state, and `automationGated` (true if trial expired and no license — every other tool will fail). ALWAYS call this first.",
158
+ inputSchema: { type: "object", properties: {} },
159
+ command: "system.status",
160
+ },
161
+ {
162
+ name: "system_list_commands",
163
+ description:
164
+ "List every verb.noun command the desktop app's automation server exposes — including ones added after this MCP version shipped. Use the long-tail ones via the `pandastudio_call` escape hatch.",
165
+ inputSchema: { type: "object", properties: {} },
166
+ command: "system.list",
167
+ },
168
+
169
+ // ── project lifecycle ───────────────────────────────────────────
170
+ {
171
+ name: "project_list",
172
+ description:
173
+ "List every project on disk (id, name, path, modifiedAt, clipCount, revision). Returns the project UUID — pass it as `id` to most other tools.",
174
+ inputSchema: { type: "object", properties: {} },
175
+ command: "project.list",
176
+ },
177
+ {
178
+ name: "project_show",
179
+ description: "Resolve a project's path + summary metadata by id or path. Cheaper than project_read.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ id: { type: "string", description: "Project UUID (preferred)" },
184
+ path: { type: "string", description: "Project file path" },
185
+ },
186
+ },
187
+ command: "project.show",
188
+ },
189
+ {
190
+ name: "project_read",
191
+ description:
192
+ "Read a project's full JSON. Returns { path, project }. Pass `project.revision` back as `expectedRevision` on subsequent writes for conflict-safe edits.",
193
+ inputSchema: {
194
+ type: "object",
195
+ properties: {
196
+ id: { type: "string" },
197
+ path: { type: "string" },
198
+ },
199
+ },
200
+ command: "project.read",
201
+ },
202
+ {
203
+ name: "project_new",
204
+ description:
205
+ "Create a new project. `withMedia` (array of absolute video file paths) imports them as clips; FFmpeg probes each duration. Returns { id, path, project }.",
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ name: { type: "string", description: "Display name" },
210
+ withMedia: {
211
+ type: "array",
212
+ items: { type: "string" },
213
+ description: "Absolute paths to video files to import as clips",
214
+ },
215
+ },
216
+ required: ["name"],
217
+ },
218
+ command: "project.new",
219
+ },
220
+ {
221
+ name: "project_open",
222
+ description: "Open the desktop editor focused on a specific project (so the user can take over).",
223
+ inputSchema: {
224
+ type: "object",
225
+ properties: {
226
+ id: { type: "string" },
227
+ path: { type: "string" },
228
+ },
229
+ },
230
+ command: "project.open",
231
+ },
232
+
233
+ // ── compose: clips, motion graphics, FX, lower-thirds ──────────
234
+ {
235
+ name: "project_add_clip",
236
+ description: "Append a video clip to the project's main track. Probes duration via FFmpeg.",
237
+ inputSchema: {
238
+ type: "object",
239
+ properties: {
240
+ id: { type: "string" },
241
+ path: { type: "string" },
242
+ media: { type: "string", description: "Absolute path to video file" },
243
+ expectedRevision: { type: "number" },
244
+ },
245
+ required: ["media"],
246
+ },
247
+ command: "project.add-clip",
248
+ },
249
+ {
250
+ name: "project_add_motion_graphic",
251
+ description:
252
+ "Drop a motion-graphic MP4 (typically the output of motion_generate) onto the timeline.",
253
+ inputSchema: {
254
+ type: "object",
255
+ properties: {
256
+ id: { type: "string" },
257
+ path: { type: "string" },
258
+ file: { type: "string", description: "Absolute path to MP4" },
259
+ durationMs: { type: "number" },
260
+ atMs: { type: "number", description: "Default: end of timeline" },
261
+ expectedRevision: { type: "number" },
262
+ },
263
+ required: ["file", "durationMs"],
264
+ },
265
+ command: "project.add-motion-graphic",
266
+ },
267
+ {
268
+ name: "project_add_fx",
269
+ description: "Drop an FX overlay (bundled fxId or custom video URL) onto the timeline.",
270
+ inputSchema: {
271
+ type: "object",
272
+ properties: {
273
+ id: { type: "string" },
274
+ path: { type: "string" },
275
+ fxId: { type: "string", description: "Bundled FX id (e.g. film-burn). Use asset_list_fx to discover." },
276
+ src: { type: "string", description: "Direct path/URL (alternative to fxId)" },
277
+ atMs: { type: "number" },
278
+ durationMs: { type: "number", description: "Default 2500ms for bundled FX" },
279
+ expectedRevision: { type: "number" },
280
+ },
281
+ required: ["atMs"],
282
+ },
283
+ command: "project.add-fx",
284
+ },
285
+ {
286
+ name: "project_add_lower_third",
287
+ description: "Drop a lower-third name plate onto the timeline.",
288
+ inputSchema: {
289
+ type: "object",
290
+ properties: {
291
+ id: { type: "string" },
292
+ path: { type: "string" },
293
+ content: { type: "string", description: "Primary text (name)" },
294
+ subtitle: { type: "string", description: "Secondary text (title)" },
295
+ atMs: { type: "number" },
296
+ durationMs: { type: "number", description: "Default 5000ms" },
297
+ designType: {
298
+ type: "string",
299
+ description: "name-bar | slash-reveal | center-stack | minimal-underline | box-reveal | corner-brackets | border-frame | split-horizontal",
300
+ },
301
+ accentColor: { type: "string" },
302
+ expectedRevision: { type: "number" },
303
+ },
304
+ required: ["content", "atMs"],
305
+ },
306
+ command: "project.add-lower-third",
307
+ },
308
+
309
+ // ── compose: visual regions ─────────────────────────────────────
310
+ {
311
+ name: "project_add_zoom",
312
+ description: "Add a zoom region — highlight a UI moment. Typical pattern: find 'click X' in transcript, drop zoom there.",
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: {
316
+ id: { type: "string" },
317
+ path: { type: "string" },
318
+ atMs: { type: "number" },
319
+ durationMs: { type: "number" },
320
+ depth: { type: "number", description: "1 (mild) - 6 (extreme). Default 3" },
321
+ focusX: { type: "number", description: "Normalised 0-1 horizontal center. Default 0.5" },
322
+ focusY: { type: "number", description: "Normalised 0-1 vertical center. Default 0.5" },
323
+ expectedRevision: { type: "number" },
324
+ },
325
+ required: ["atMs", "durationMs"],
326
+ },
327
+ command: "project.add-zoom",
328
+ },
329
+ {
330
+ name: "project_add_trim",
331
+ description: "Mark a span the exporter SKIPS. Use directly for manual cuts.",
332
+ inputSchema: {
333
+ type: "object",
334
+ properties: {
335
+ id: { type: "string" },
336
+ path: { type: "string" },
337
+ startMs: { type: "number" },
338
+ endMs: { type: "number" },
339
+ expectedRevision: { type: "number" },
340
+ },
341
+ required: ["startMs", "endMs"],
342
+ },
343
+ command: "project.add-trim",
344
+ },
345
+ {
346
+ name: "project_add_speed",
347
+ description: "Mark a span to play at a different speed. Useful for fast-forwarding setup.",
348
+ inputSchema: {
349
+ type: "object",
350
+ properties: {
351
+ id: { type: "string" },
352
+ path: { type: "string" },
353
+ startMs: { type: "number" },
354
+ endMs: { type: "number" },
355
+ speed: { type: "number", description: "0.25, 0.5, 0.75, 1.25, 1.5, 1.75, or 2" },
356
+ expectedRevision: { type: "number" },
357
+ },
358
+ required: ["startMs", "endMs", "speed"],
359
+ },
360
+ command: "project.add-speed",
361
+ },
362
+
363
+ // ── transcript-based editing ────────────────────────────────────
364
+ {
365
+ name: "transcript_transcribe",
366
+ description:
367
+ "Transcribe each clip's audio with word-level timestamps via the bundled Parakeet TDT model. ASYNC — returns { jobId }; call job_wait. After this, transcript_get returns the merged edited-time transcript.",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ id: { type: "string" },
372
+ path: { type: "string" },
373
+ clipId: { type: "string", description: "Optional - transcribe only this clip" },
374
+ },
375
+ },
376
+ command: "transcript.transcribe",
377
+ },
378
+ {
379
+ name: "transcript_get",
380
+ description:
381
+ "Return the merged edited-time transcript: every word with id, text, startMs, endMs in EDITED-TIMELINE coordinates. Use word IDs as input to transcript_delete_words.",
382
+ inputSchema: {
383
+ type: "object",
384
+ properties: {
385
+ id: { type: "string" },
386
+ path: { type: "string" },
387
+ },
388
+ },
389
+ command: "transcript.get",
390
+ },
391
+ {
392
+ name: "transcript_delete_words",
393
+ description:
394
+ "Delete words from the project — internally translates each word's [startMs, endMs] into a trim region the exporter skips. THIS is the editor's 'click word, hit delete' workflow, callable from MCP.",
395
+ inputSchema: {
396
+ type: "object",
397
+ properties: {
398
+ id: { type: "string" },
399
+ path: { type: "string" },
400
+ wordIds: {
401
+ type: "array",
402
+ items: { type: "string" },
403
+ description: "Word IDs from transcript_get",
404
+ },
405
+ expectedRevision: { type: "number" },
406
+ },
407
+ required: ["wordIds"],
408
+ },
409
+ command: "transcript.delete-words",
410
+ },
411
+ {
412
+ name: "transcript_remove_fillers",
413
+ description: "Auto-detect filler words ('um', 'uh', 'like', 'you know', ...) plus immediately-repeated words and bulk-trim them.",
414
+ inputSchema: {
415
+ type: "object",
416
+ properties: {
417
+ id: { type: "string" },
418
+ path: { type: "string" },
419
+ includeRepeats: { type: "boolean", description: "Default true" },
420
+ expectedRevision: { type: "number" },
421
+ },
422
+ },
423
+ command: "transcript.remove-fillers",
424
+ },
425
+ {
426
+ name: "transcript_search",
427
+ description: "Find every occurrence of a phrase in the merged transcript. Returns matches with their word IDs.",
428
+ inputSchema: {
429
+ type: "object",
430
+ properties: {
431
+ id: { type: "string" },
432
+ path: { type: "string" },
433
+ query: { type: "string" },
434
+ },
435
+ required: ["query"],
436
+ },
437
+ command: "transcript.search",
438
+ },
439
+
440
+ // ── audio ───────────────────────────────────────────────────────
441
+ {
442
+ name: "audio_clean",
443
+ description:
444
+ "Run DeepFilter denoising on each clip's audio. ASYNC. Writes a sibling .cleaned.wav and points clip.cleanedAudioPath at it; export auto-uses it.",
445
+ inputSchema: {
446
+ type: "object",
447
+ properties: {
448
+ id: { type: "string" },
449
+ path: { type: "string" },
450
+ clipId: { type: "string", description: "Optional - clean only this clip" },
451
+ },
452
+ },
453
+ command: "audio.clean",
454
+ },
455
+
456
+ // ── captions ────────────────────────────────────────────────────
457
+ {
458
+ name: "caption_toggle",
459
+ description: "Enable or disable captions for the whole project. Requires transcript first.",
460
+ inputSchema: {
461
+ type: "object",
462
+ properties: {
463
+ id: { type: "string" },
464
+ path: { type: "string" },
465
+ enabled: { type: "boolean" },
466
+ expectedRevision: { type: "number" },
467
+ },
468
+ required: ["enabled"],
469
+ },
470
+ command: "caption.toggle",
471
+ },
472
+ {
473
+ name: "caption_set_template",
474
+ description: "Pick a caption template: classic, modern, minimal, bold, spotlight, boxed, neon, or colored.",
475
+ inputSchema: {
476
+ type: "object",
477
+ properties: {
478
+ id: { type: "string" },
479
+ path: { type: "string" },
480
+ templateId: { type: "string" },
481
+ expectedRevision: { type: "number" },
482
+ },
483
+ required: ["templateId"],
484
+ },
485
+ command: "caption.set-template",
486
+ },
487
+
488
+ // ── motion graphics ─────────────────────────────────────────────
489
+ {
490
+ name: "motion_list",
491
+ description: "List every motion-graphic template (id, name, slots, defaults, aspectRatios). 19 templates ship today.",
492
+ inputSchema: { type: "object", properties: {} },
493
+ command: "motion.list",
494
+ },
495
+ {
496
+ name: "motion_themes",
497
+ description: "List every style-pack theme (color overrides). default, mr-beast, mkbhd, kurzgesagt, veritasium.",
498
+ inputSchema: { type: "object", properties: {} },
499
+ command: "motion.themes",
500
+ },
501
+ {
502
+ name: "motion_generate",
503
+ description:
504
+ "Render a motion graphic asynchronously. Returns { jobId, outputPath }; call job_wait. Output is an MP4 you can pass to project_add_motion_graphic.",
505
+ inputSchema: {
506
+ type: "object",
507
+ properties: {
508
+ templateId: { type: "string", description: "From motion_list" },
509
+ slots: { type: "object", description: "Slot values (theme already resolved)" },
510
+ aspectRatio: { type: "string", description: "16:9 | 9:16 | 1:1" },
511
+ outputName: { type: "string", description: "Optional filename stem" },
512
+ },
513
+ required: ["templateId", "slots"],
514
+ },
515
+ command: "motion.generate",
516
+ },
517
+
518
+ // ── assets ──────────────────────────────────────────────────────
519
+ {
520
+ name: "asset_list_sounds",
521
+ description: "List every bundled sound effect (id, name, category, absolute path).",
522
+ inputSchema: { type: "object", properties: {} },
523
+ command: "asset.list-sounds",
524
+ },
525
+ {
526
+ name: "asset_list_fx",
527
+ description: "List every bundled FX video overlay (id, title, blendMode, defaults, absolute path).",
528
+ inputSchema: { type: "object", properties: {} },
529
+ command: "asset.list-fx",
530
+ },
531
+
532
+ // ── project-aware LLM ──────────────────────────────────────────
533
+ {
534
+ name: "llm_generate_title",
535
+ description: "Generate a YouTube-ready title from the project's merged transcript using the bundled local LLM.",
536
+ inputSchema: {
537
+ type: "object",
538
+ properties: {
539
+ id: { type: "string" },
540
+ path: { type: "string" },
541
+ maxChars: { type: "number", description: "Default 70" },
542
+ },
543
+ },
544
+ command: "llm.generate-title",
545
+ },
546
+ {
547
+ name: "llm_generate_description",
548
+ description: "Generate a YouTube description (3-5 sentences) from the merged transcript.",
549
+ inputSchema: {
550
+ type: "object",
551
+ properties: {
552
+ id: { type: "string" },
553
+ path: { type: "string" },
554
+ maxChars: { type: "number", description: "Default 400" },
555
+ },
556
+ },
557
+ command: "llm.generate-description",
558
+ },
559
+ {
560
+ name: "llm_generate_timestamps",
561
+ description: "Generate YouTube-style chapter timestamps from the merged transcript.",
562
+ inputSchema: {
563
+ type: "object",
564
+ properties: {
565
+ id: { type: "string" },
566
+ path: { type: "string" },
567
+ maxChapters: { type: "number", description: "Default 8" },
568
+ },
569
+ },
570
+ command: "llm.generate-timestamps",
571
+ },
572
+
573
+ // ── export (the centerpiece) ────────────────────────────────────
574
+ {
575
+ name: "export_start",
576
+ description:
577
+ "Render the project to MP4 via the same Skia native render-helper the editor's Export button uses. ASYNC — returns { jobId, outputPath }; call job_wait. Honours every region/style/caption/FX/lower-third/motion-graphic.",
578
+ inputSchema: {
579
+ type: "object",
580
+ properties: {
581
+ id: { type: "string" },
582
+ path: { type: "string" },
583
+ outputPath: { type: "string", description: "Where to write the MP4. Default: recordings dir" },
584
+ quality: { type: "string", description: "draft | standard | high | ultra. Default high" },
585
+ },
586
+ },
587
+ command: "export.start",
588
+ },
589
+ {
590
+ name: "export_list",
591
+ description: "List every entry in the export library, newest-first.",
592
+ inputSchema: { type: "object", properties: {} },
593
+ command: "export.list",
594
+ },
595
+
596
+ // ── preview overlay (v1.9.2) ────────────────────────────────────
597
+ {
598
+ name: "preview_show",
599
+ description:
600
+ "Pop up a small floating, always-on-top window showing the project's live preview. ~1-2s boot. Singleton — second call reuses the window. Lets you show the user what you just did without them leaving the chat.",
601
+ inputSchema: {
602
+ type: "object",
603
+ properties: {
604
+ id: { type: "string" },
605
+ path: { type: "string" },
606
+ atMs: { type: "number", description: "Start playhead here. Default 0" },
607
+ autoplay: { type: "boolean", description: "Default true" },
608
+ },
609
+ },
610
+ command: "preview.show",
611
+ },
612
+ {
613
+ name: "preview_seek",
614
+ description: "Move the open preview overlay's playhead.",
615
+ inputSchema: {
616
+ type: "object",
617
+ properties: { atMs: { type: "number" } },
618
+ required: ["atMs"],
619
+ },
620
+ command: "preview.seek",
621
+ },
622
+ {
623
+ name: "preview_hide",
624
+ description: "Close the preview overlay.",
625
+ inputSchema: { type: "object", properties: {} },
626
+ command: "preview.hide",
627
+ },
628
+
629
+ // ── async jobs ─────────────────────────────────────────────────
630
+ {
631
+ name: "job_wait",
632
+ description:
633
+ "Block server-side until an async job (transcribe, audio_clean, motion_generate, export_start) reaches a terminal state. Default timeout 60s, max 5 min. Always call this after kicking off async work.",
634
+ inputSchema: {
635
+ type: "object",
636
+ properties: {
637
+ id: { type: "string", description: "Job id from the async tool's response" },
638
+ timeoutMs: { type: "number", description: "Max wait. Default 60_000, hard-capped at 300_000" },
639
+ },
640
+ required: ["id"],
641
+ },
642
+ command: "job.wait",
643
+ },
644
+ {
645
+ name: "job_get",
646
+ description: "Snapshot a job's current status + progress + result without blocking.",
647
+ inputSchema: {
648
+ type: "object",
649
+ properties: { id: { type: "string" } },
650
+ required: ["id"],
651
+ },
652
+ command: "job.get",
653
+ },
654
+
655
+ // ── escape hatch ────────────────────────────────────────────────
656
+ {
657
+ name: "pandastudio_call",
658
+ description:
659
+ "Generic dispatch — call ANY verb.noun including ones added to PandaStudio after this MCP server version shipped. Use system_list_commands first to discover available commands. Args object is passed through unchanged.",
660
+ inputSchema: {
661
+ type: "object",
662
+ properties: {
663
+ command: { type: "string", description: "verb.noun, e.g. project.split-clip" },
664
+ args: { type: "object", description: "Arguments object" },
665
+ },
666
+ required: ["command"],
667
+ },
668
+ command: null, // handled specially in dispatch
669
+ },
670
+ ];
671
+
672
+ // ── MCP server boot ───────────────────────────────────────────────────
673
+
674
+ const server = new Server(
675
+ {
676
+ name: "pandastudio",
677
+ version: "1.9.3",
678
+ },
679
+ {
680
+ capabilities: {
681
+ tools: {},
682
+ },
683
+ },
684
+ );
685
+
686
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
687
+ return {
688
+ tools: TOOLS.map((t) => ({
689
+ name: t.name,
690
+ description: t.description,
691
+ inputSchema: t.inputSchema,
692
+ })),
693
+ };
694
+ });
695
+
696
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
697
+ const tool = TOOLS.find((t) => t.name === request.params.name);
698
+ if (!tool) {
699
+ return {
700
+ content: [{ type: "text", text: `unknown tool: ${request.params.name}` }],
701
+ isError: true,
702
+ };
703
+ }
704
+
705
+ const args = request.params.arguments ?? {};
706
+ let command;
707
+ let dispatchArgs;
708
+
709
+ if (tool.name === "pandastudio_call") {
710
+ // Escape hatch — caller supplies command + args directly
711
+ command = args.command;
712
+ dispatchArgs = args.args ?? {};
713
+ if (typeof command !== "string" || !command.includes(".")) {
714
+ return {
715
+ content: [{ type: "text", text: "pandastudio_call requires `command` of the form 'verb.noun'" }],
716
+ isError: true,
717
+ };
718
+ }
719
+ } else {
720
+ command = tool.command;
721
+ dispatchArgs = args;
722
+ }
723
+
724
+ try {
725
+ const result = await callPandastudio(command, dispatchArgs);
726
+ // Format the response for the MCP client. Every tool returns
727
+ // JSON; we wrap it in a text block so the agent can read it
728
+ // in their context window. For tools where a structured
729
+ // content block matters (e.g. preview_show returning an
730
+ // image), we'd add a richer content array — for now text is
731
+ // sufficient and matches what `pandastudio --json` returns.
732
+ return {
733
+ content: [
734
+ {
735
+ type: "text",
736
+ text: JSON.stringify(result, null, 2),
737
+ },
738
+ ],
739
+ isError: result?.ok === false,
740
+ };
741
+ } catch (err) {
742
+ return {
743
+ content: [{ type: "text", text: `tool error: ${err?.message ?? String(err)}` }],
744
+ isError: true,
745
+ };
746
+ }
747
+ });
748
+
749
+ // Start stdio transport (the MCP standard for local subprocess
750
+ // servers). For HTTP/SSE transports there's @modelcontextprotocol/
751
+ // sdk/server/sse — overkill for our use case since this server is
752
+ // always spawned by an MCP client (Cursor / Continue / Claude
753
+ // Desktop) on the same machine.
754
+ const transport = new StdioServerTransport();
755
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@writepanda/mcp",
3
+ "version": "1.9.3",
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
+ "keywords": [
6
+ "pandastudio",
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "video-editor",
10
+ "agent",
11
+ "cursor",
12
+ "continue",
13
+ "cline",
14
+ "claude-desktop"
15
+ ],
16
+ "author": "Kamala Kannan Sankarraj",
17
+ "license": "MIT",
18
+ "homepage": "https://www.writepanda.ai/cli",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/kamskans/openscreen.git",
22
+ "directory": "packages/mcp"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/kamskans/openscreen/issues"
26
+ },
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "bin": {
32
+ "writepanda-mcp": "./bin/server.mjs"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.0.4"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }