paraplex-mcp 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pixel Point
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 CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # paraplex-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect Claude Code — or any MCP client — to the [Paraplex](https://paraplex.ai)
4
+ tab you have open. Paraplex is a 3D motion design studio in the browser, with an
5
+ AI assistant that edits real layers and keyframes. With this server, an agent in
6
+ your terminal can build and change scenes in your tab with the same tools the
7
+ assistant uses, look at frames as it goes, and export.
8
+
9
+ ```bash
10
+ claude mcp add paraplex -- npx -y paraplex-mcp
11
+ ```
12
+
13
+ Then ask Claude Code for a piece — "a 10-second launch video with three glass
14
+ cards and our logo" — and it will tell you how to connect:
15
+
16
+ 1. Open Paraplex at <https://app.paraplex.ai>.
17
+ 2. **Menu › Connect an agent**, and enter the port and the six-digit code Claude
18
+ Code gives you.
19
+
20
+ A banner shows in your tab while an agent is connected; disconnect it there any
21
+ time.
22
+
23
+ ## What it can do
24
+
25
+ | Tool | What it does |
26
+ | --- | --- |
27
+ | `connection` | Whether a tab is attached; if not, the port and code to give you |
28
+ | `scene` | The open scene: canvas, duration, time, every layer |
29
+ | `list_tools`, `run_tool` | The assistant's verbs (`build_scene`, `add_text`, `set_keyframes`, `add_camera_move`…) and running one |
30
+ | `list_templates`, `apply_template`, `new_project` | Start from a template or a blank file |
31
+ | `set_time`, `frame`, `frames`, `export_png` | Look at the piece at any time, as its export draws it |
32
+ | `scene_facts` | Where every layer lands in the frame, type sizes on the frame, the camera |
33
+ | `export_video` | Export the video to a file on this computer (`./paraplex-exports`, or `PARAPLEX_OUT_DIR`) |
34
+ | `import_hyperframes` | Bring in a HyperFrames project from a folder or .zip on this computer: one code layer, its sound on the timeline |
35
+ | `save_project` | Save the scene as a project in your browser |
36
+
37
+ Every edit lands in your tab as one undo step, so you can step back through
38
+ anything the agent did.
39
+
40
+ ## Privacy and safety
41
+
42
+ - The server listens on this computer only (port 47821; pass another as
43
+ `npx paraplex-mcp 48000`).
44
+ - Only a Paraplex page can connect, and only with the one-time code.
45
+ - Nothing is sent anywhere else: the scene stays in your tab, and exports are
46
+ written where you ran the server.
47
+ - AI used inside Paraplex is metered by your Paraplex plan as usual; the agent's
48
+ own model is your Claude Code's.
49
+
50
+ ## Browsers
51
+
52
+ Chrome, Edge or Arc (other browsers may not let a website reach your own
53
+ computer). If your browser asks to let app.paraplex.ai connect to devices on
54
+ your local network, allow it: that is this server.
55
+
56
+ ## License
57
+
58
+ MIT © Paraplex Digital, LLC
package/bin.mjs ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { createMcpHandler, serveMcpOverStdio } from "./lib/mcp.mjs";
8
+ import { attachedTabTools, attachParaplexTab, PARAPLEX_ATTACH_PORT } from "./lib/paraplex-tab.mjs";
9
+
10
+ /**
11
+ * paraplex-mcp: the Paraplex MCP server in attach mode, for anyone.
12
+ *
13
+ * claude mcp add paraplex -- npx -y paraplex-mcp
14
+ *
15
+ * It opens no editor of its own: it waits on this computer for the Paraplex
16
+ * tab you have open to connect (in Paraplex: Menu → Connect an agent, with
17
+ * the port and the one-time code it gives), then sends its calls there —
18
+ * every edit lands in your tab as one undo step. Only a Paraplex page may
19
+ * connect, only with the code. Nothing is sent anywhere else.
20
+ *
21
+ * npx paraplex-mcp [port] # another port than 47821
22
+ * PARAPLEX_OUT_DIR=… # where export_video writes (./paraplex-exports)
23
+ */
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const { version } = JSON.parse(readFileSync(join(here, "package.json"), "utf8"));
27
+ // stdout is the MCP channel: everything said to the person goes to stderr.
28
+ const log = (text) => process.stderr.write(`[paraplex] ${text}\n`);
29
+ const portArg = process.argv.slice(2).find((arg) => /^\d+$/.test(arg)) ?? process.env.PARAPLEX_PORT;
30
+ const port = portArg !== undefined ? Number(portArg) : PARAPLEX_ATTACH_PORT;
31
+ const outDir = process.env.PARAPLEX_OUT_DIR ?? join(process.cwd(), "paraplex-exports");
32
+
33
+ let tab;
34
+
35
+ try {
36
+ tab = await attachParaplexTab({ log, port });
37
+ } catch (error) {
38
+ log(`Could not listen on port ${port}: ${error?.message ?? error}. Pass another: npx paraplex-mcp <port>`);
39
+ process.exit(1);
40
+ }
41
+
42
+ const settled = serveMcpOverStdio(createMcpHandler({ name: "paraplex", tools: attachedTabTools(tab, { outDir }), version }));
43
+
44
+ process.stdin.on("end", async () => {
45
+ await settled();
46
+ await tab.close();
47
+ process.exit(0);
48
+ });
package/lib/mcp.mjs ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The Model Context Protocol, the part a tool server needs (roadmap v10
3
+ * wave 1): JSON-RPC 2.0 over newline-delimited stdio — `initialize`,
4
+ * `tools/list`, `tools/call`, `ping`. Pure: the handler takes the tools
5
+ * and returns responses, so a test can drive it without a browser.
6
+ */
7
+
8
+ export const MCP_PROTOCOL_VERSION = "2024-11-05";
9
+
10
+ export function createMcpHandler({ name, tools, version }) {
11
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
12
+
13
+ return async function handle(message) {
14
+ const { id, method, params } = message;
15
+ const reply = (result) => ({ id, jsonrpc: "2.0", result });
16
+ const fail = (code, text) => ({ error: { code, message: text }, id, jsonrpc: "2.0" });
17
+
18
+ if (method === "initialize") {
19
+ return reply({ capabilities: { tools: {} }, protocolVersion: MCP_PROTOCOL_VERSION, serverInfo: { name, version } });
20
+ }
21
+
22
+ if (method === "notifications/initialized" || method?.startsWith("notifications/")) {
23
+ return null;
24
+ }
25
+
26
+ if (method === "ping") {
27
+ return reply({});
28
+ }
29
+
30
+ if (method === "tools/list") {
31
+ return reply({ tools: tools.map(({ description, inputSchema, name: toolName }) => ({ description, inputSchema: inputSchema ?? { properties: {}, type: "object" }, name: toolName })) });
32
+ }
33
+
34
+ if (method === "tools/call") {
35
+ const tool = byName.get(params?.name);
36
+
37
+ if (!tool) {
38
+ return fail(-32602, `Unknown tool: ${params?.name}`);
39
+ }
40
+
41
+ try {
42
+ const content = await tool.run(params.arguments ?? {});
43
+
44
+ // A tool answers with content blocks (each with a `type`) or with
45
+ // any value, which is shown as text — an array of templates is data,
46
+ // not a block list.
47
+ const isBlocks = Array.isArray(content) && content.length > 0 && content.every((block) => block && typeof block === "object" && typeof block.type === "string");
48
+
49
+ return reply({ content: isBlocks ? content : [{ text: typeof content === "string" ? content : JSON.stringify(content, null, 2), type: "text" }] });
50
+ } catch (error) {
51
+ return reply({ content: [{ text: `Error: ${error?.message ?? String(error)}`, type: "text" }], isError: true });
52
+ }
53
+ }
54
+
55
+ if (id === undefined) {
56
+ return null;
57
+ }
58
+
59
+ return fail(-32601, `Method not found: ${method}`);
60
+ };
61
+ }
62
+
63
+ /** Runs a handler over stdin/stdout, one JSON message per line. */
64
+ export function serveMcpOverStdio(handle, { input = process.stdin, output = process.stdout } = {}) {
65
+ let buffer = "";
66
+ const inFlight = new Set();
67
+
68
+ input.setEncoding("utf8");
69
+ input.on("data", (chunk) => {
70
+ buffer += chunk;
71
+
72
+ let newline = buffer.indexOf("\n");
73
+
74
+ while (newline >= 0) {
75
+ const line = buffer.slice(0, newline).trim();
76
+
77
+ buffer = buffer.slice(newline + 1);
78
+ newline = buffer.indexOf("\n");
79
+
80
+ if (!line) {
81
+ continue;
82
+ }
83
+
84
+ let message;
85
+
86
+ try {
87
+ message = JSON.parse(line);
88
+ } catch {
89
+ output.write(`${JSON.stringify({ error: { code: -32700, message: "Parse error" }, id: null, jsonrpc: "2.0" })}\n`);
90
+ continue;
91
+ }
92
+
93
+ const pending = handle(message).then((response) => {
94
+ if (response) {
95
+ output.write(`${JSON.stringify(response)}\n`);
96
+ }
97
+ });
98
+
99
+ inFlight.add(pending);
100
+ void pending.finally(() => inFlight.delete(pending));
101
+ }
102
+ });
103
+
104
+ /** Resolves once every call received so far has answered — for a clean exit when stdin closes. */
105
+ return () => Promise.allSettled([...inFlight]);
106
+ }
107
+
108
+ /** Image content for a tool result, from a PNG buffer or data URL. */
109
+ export function imageContent(pngOrDataUrl, text) {
110
+ const data = Buffer.isBuffer(pngOrDataUrl) ? pngOrDataUrl.toString("base64") : String(pngOrDataUrl).replace(/^data:[^,]+,/, "");
111
+ const mimeType = Buffer.isBuffer(pngOrDataUrl) ? "image/png" : (/^data:([^;]+)/.exec(String(pngOrDataUrl))?.[1] ?? "image/png");
112
+ const content = [{ data, mimeType, type: "image" }];
113
+
114
+ return text ? [{ text, type: "text" }, ...content] : content;
115
+ }
@@ -0,0 +1,365 @@
1
+ import { randomInt } from "node:crypto";
2
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+
5
+ import { imageContent } from "./mcp.mjs";
6
+ import { serveWebSocket } from "./ws-server.mjs";
7
+
8
+ /**
9
+ * The agent bridge attached to the person's own tab (roadmap v11, wave 5):
10
+ * where `npm run agent` opens a headless editor of its own, `npm run agent
11
+ * -- attach` waits for the Paraplex tab the person has open to connect —
12
+ * Menu → Connect an agent, with the port and the one-time code printed
13
+ * here — and sends its calls there. Only a page from Paraplex's origins
14
+ * may connect, only with the code, and only the named calls the tab's
15
+ * connector answers exist (agent-connector.ts); no code is sent into the
16
+ * tab.
17
+ */
18
+
19
+ export const PARAPLEX_ATTACH_PORT = 47821;
20
+
21
+ /** What a project folder may send the tab: past this, its media will not fit a layer anyway. */
22
+ const PROJECT_MAX_BYTES = 96 * 1024 * 1024;
23
+ const PROJECT_MAX_FILES = 4000;
24
+ const PROJECT_SKIPPED = new Set([".git", ".DS_Store", "__MACOSX", "node_modules"]);
25
+
26
+ /**
27
+ * A HyperFrames project for the tab (roadmap v20 WB.2): a folder's files as
28
+ * path → base64 (system and dependency folders left out), or a zip's bytes
29
+ * as base64 — the tab makes it one code layer.
30
+ */
31
+ export function readParaplexProject(path) {
32
+ const root = resolve(path);
33
+ const stat = statSync(root);
34
+
35
+ if (stat.isFile()) {
36
+ if (!/\.zip$/i.test(root)) {
37
+ throw new Error(`${path} is a file: give the project's folder, or a .zip of it.`);
38
+ }
39
+
40
+ if (stat.size > PROJECT_MAX_BYTES) {
41
+ throw new Error(`${path} is ${Math.round(stat.size / 1024 / 1024)} MB, more than a project can bring.`);
42
+ }
43
+
44
+ return { name: basename(root).replace(/\.zip$/i, ""), zip: readFileSync(root).toString("base64") };
45
+ }
46
+
47
+ const files = {};
48
+ let bytes = 0;
49
+ let count = 0;
50
+ const walk = (dir) => {
51
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
52
+ if (PROJECT_SKIPPED.has(entry.name)) {
53
+ continue;
54
+ }
55
+
56
+ const full = join(dir, entry.name);
57
+
58
+ if (entry.isDirectory()) {
59
+ walk(full);
60
+ } else if (entry.isFile()) {
61
+ count += 1;
62
+ bytes += statSync(full).size;
63
+
64
+ if (count > PROJECT_MAX_FILES || bytes > PROJECT_MAX_BYTES) {
65
+ throw new Error(`${path} holds more than a project can bring (${PROJECT_MAX_FILES} files, ${PROJECT_MAX_BYTES / 1024 / 1024} MB). Leave renders and other large files out.`);
66
+ }
67
+
68
+ files[relative(root, full).split("\\").join("/")] = readFileSync(full).toString("base64");
69
+ }
70
+ }
71
+ };
72
+
73
+ walk(root);
74
+
75
+ return { files, name: basename(root) };
76
+ }
77
+
78
+ /** Paraplex's own origins, and a local dev server's; PARAPLEX_ORIGIN adds one. */
79
+ export function paraplexOriginAllowed(origin) {
80
+ const extra = process.env.PARAPLEX_ORIGIN;
81
+
82
+ return (
83
+ (typeof extra === "string" && extra.length > 0 && origin === extra) ||
84
+ /^https:\/\/(www\.|app\.)?paraplex\.ai$/.test(origin) ||
85
+ /^https:\/\/([a-z0-9-]+--)?paraplex[a-z0-9-]*\.netlify\.app$/.test(origin) ||
86
+ /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Starts the local endpoint and returns `call(method, params)`: sent to the
92
+ * attached tab, answered by it; waits (up to a few minutes) for a tab when
93
+ * none is attached yet.
94
+ */
95
+ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, code = String(randomInt(100000, 1000000)), log = () => {}, port = PARAPLEX_ATTACH_PORT } = {}) {
96
+ let tab = null;
97
+ let next = 1;
98
+ const pending = new Map();
99
+ let waiters = [];
100
+
101
+ const server = await serveWebSocket({
102
+ allowOrigin,
103
+ onConnection: (peer) => {
104
+ peer.onMessage = (text) => {
105
+ let data;
106
+
107
+ try {
108
+ data = JSON.parse(text);
109
+ } catch {
110
+ return;
111
+ }
112
+
113
+ if (data.type === "hello") {
114
+ if (String(data.code ?? "") !== code) {
115
+ peer.send(JSON.stringify({ reason: "That code does not match the one the agent printed.", type: "refused" }));
116
+ peer.close();
117
+ return;
118
+ }
119
+
120
+ // A second tab takes over from the first.
121
+ if (tab && tab !== peer) {
122
+ tab.close();
123
+ }
124
+
125
+ tab = peer;
126
+ peer.send(JSON.stringify({ type: "welcome" }));
127
+ log("A Paraplex tab is attached.");
128
+
129
+ for (const wake of waiters) {
130
+ wake();
131
+ }
132
+
133
+ waiters = [];
134
+ } else if (data.type === "result" && pending.has(data.id)) {
135
+ const { reject, resolve } = pending.get(data.id);
136
+
137
+ pending.delete(data.id);
138
+
139
+ if (data.ok) {
140
+ resolve(data.value);
141
+ } else {
142
+ reject(new Error(data.error ?? "The tab could not do that."));
143
+ }
144
+ }
145
+ };
146
+ peer.onClose = () => {
147
+ if (tab === peer) {
148
+ tab = null;
149
+
150
+ for (const { reject } of pending.values()) {
151
+ reject(new Error("The tab disconnected."));
152
+ }
153
+
154
+ pending.clear();
155
+ log("The tab disconnected.");
156
+ }
157
+ };
158
+ },
159
+ port,
160
+ });
161
+ const address = server.address();
162
+ const listening = typeof address === "object" && address ? address.port : port;
163
+
164
+ log(`Waiting for a Paraplex tab: in Paraplex, Menu → Connect an agent, port ${listening}, code ${code}.`);
165
+
166
+ const call = async (method, params = {}) => {
167
+ if (!tab) {
168
+ await new Promise((resolve, reject) => {
169
+ const timer = setTimeout(() => reject(new Error(`No Paraplex tab is attached. In Paraplex: Menu → Connect an agent, port ${listening}, code ${code}.`)), 180_000);
170
+
171
+ waiters.push(() => {
172
+ clearTimeout(timer);
173
+ resolve();
174
+ });
175
+ });
176
+ }
177
+
178
+ const id = next++;
179
+
180
+ return new Promise((resolve, reject) => {
181
+ const timer = setTimeout(() => {
182
+ if (pending.delete(id)) {
183
+ reject(new Error("The tab did not answer in time."));
184
+ }
185
+ }, 600_000);
186
+
187
+ pending.set(id, {
188
+ reject: (error) => {
189
+ clearTimeout(timer);
190
+ reject(error);
191
+ },
192
+ resolve: (value) => {
193
+ clearTimeout(timer);
194
+ resolve(value);
195
+ },
196
+ });
197
+ tab.send(JSON.stringify({ id, method, params, type: "call" }));
198
+ });
199
+ };
200
+
201
+ return {
202
+ attached: () => tab !== null,
203
+ call,
204
+ close: () => new Promise((resolve) => {
205
+ tab?.close();
206
+ server.close(() => resolve());
207
+ }),
208
+ code,
209
+ port: listening,
210
+ };
211
+ }
212
+
213
+ /** The MCP tools over an attached tab: the headless server's, as far as named calls reach. */
214
+ export function attachedTabTools(tab, { outDir }) {
215
+ return [
216
+ {
217
+ // The person cannot see this server's log from Claude Code: the agent
218
+ // asks for the port and code here and tells them.
219
+ description:
220
+ "Whether a Paraplex tab is attached, and if not, what to tell the person: in Paraplex, Menu → Connect an agent, then this port and code. Call it first; say the code to the person exactly.",
221
+ name: "connection",
222
+ run: () =>
223
+ tab.attached()
224
+ ? { attached: true, port: tab.port }
225
+ : {
226
+ attached: false,
227
+ code: tab.code,
228
+ port: tab.port,
229
+ tell: `Open Paraplex in your browser, then Menu → Connect an agent, and enter port ${tab.port} and code ${tab.code}.`,
230
+ },
231
+ },
232
+ {
233
+ description: "The built-in templates: id, label and categories (the first is its section on the home page, e.g. app-promo, brand-promo, starter, website-hero-visuals, titles-text, infographics).",
234
+ name: "list_templates",
235
+ run: () => tab.call("templates"),
236
+ },
237
+ {
238
+ description: "A blank 1920×1080 project in the attached tab (the scene being left is saved as the editor saves it).",
239
+ name: "new_project",
240
+ run: async () => {
241
+ await tab.call("newProject");
242
+
243
+ return "A new project.";
244
+ },
245
+ },
246
+ {
247
+ description: "Apply a built-in template by id, as the gallery does.",
248
+ inputSchema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" },
249
+ name: "apply_template",
250
+ run: async ({ id }) => {
251
+ await tab.call("applyTemplate", { id });
252
+
253
+ return tab.call("scene");
254
+ },
255
+ },
256
+ {
257
+ description: "The open scene: canvas, duration, current time, and every layer with its kind, name, clip and parent.",
258
+ name: "scene",
259
+ run: () => tab.call("scene"),
260
+ },
261
+ {
262
+ description: "Move the playhead to a time in seconds.",
263
+ inputSchema: { properties: { seconds: { type: "number" } }, required: ["seconds"], type: "object" },
264
+ name: "set_time",
265
+ run: async ({ seconds }) => `At ${await tab.call("setTime", { seconds })}s.`,
266
+ },
267
+ {
268
+ description: "A picture of the frame at a time (or the current one), through the export path — the way to look at what you made.",
269
+ inputSchema: { properties: { seconds: { type: "number" } }, type: "object" },
270
+ name: "frame",
271
+ run: async ({ seconds }) => {
272
+ const dataUrl = await tab.call("exportPng", seconds === undefined ? {} : { seconds });
273
+
274
+ return dataUrl ? imageContent(dataUrl, seconds === undefined ? "The frame now." : `The frame at ${seconds}s.`) : "No editor.";
275
+ },
276
+ },
277
+ {
278
+ description: "The frame at several times (default: four across the piece), one picture each.",
279
+ inputSchema: { properties: { times: { items: { type: "number" }, type: "array" } }, type: "object" },
280
+ name: "frames",
281
+ run: async ({ times }) => {
282
+ const scene = await tab.call("scene");
283
+ const moments = times?.length ? times : Array.from({ length: 4 }, (_, index) => Number((((scene?.durationSeconds ?? 8) * (index + 0.5)) / 4).toFixed(2)));
284
+ const content = [];
285
+
286
+ for (const seconds of moments) {
287
+ const dataUrl = await tab.call("exportPng", { seconds });
288
+
289
+ if (dataUrl) {
290
+ content.push(...imageContent(dataUrl, `${seconds}s`));
291
+ }
292
+ }
293
+
294
+ return content.length > 0 ? content : "No editor.";
295
+ },
296
+ },
297
+ {
298
+ description: "The assistant's tools — every verb that edits the scene — with their input schemas. Run one with run_tool.",
299
+ name: "list_tools",
300
+ run: () => tab.call("tools"),
301
+ },
302
+ {
303
+ description: "Run one of the assistant's tools in the attached tab (see list_tools), e.g. build_scene, add_text, set_property, add_camera_move — each call one undo step there.",
304
+ inputSchema: { properties: { input: { type: "object" }, name: { type: "string" } }, required: ["name"], type: "object" },
305
+ name: "run_tool",
306
+ run: ({ input, name }) => tab.call("runTool", { input: input ?? {}, name }),
307
+ },
308
+ {
309
+ description: "The scene at the current time through the export path (what a PNG export produces), as an image.",
310
+ name: "export_png",
311
+ run: async () => {
312
+ const dataUrl = await tab.call("exportPng");
313
+
314
+ return dataUrl ? imageContent(dataUrl, "The export.") : "No editor.";
315
+ },
316
+ },
317
+ {
318
+ description: "Export the scene as a video, written to a file in the export folder (or at `path`); answers with where it went.",
319
+ inputSchema: { properties: { path: { type: "string" } }, type: "object" },
320
+ name: "export_video",
321
+ run: async ({ path }) => {
322
+ const result = await tab.call("exportVideo");
323
+
324
+ if (!result) {
325
+ return "No editor.";
326
+ }
327
+
328
+ const target = path ?? join(outDir, result.fileName);
329
+
330
+ mkdirSync(dirname(target), { recursive: true });
331
+ writeFileSync(target, Buffer.from(String(result.dataUrl).replace(/^data:[^,]+,/, ""), "base64"));
332
+
333
+ return `Written: ${target}`;
334
+ },
335
+ },
336
+ {
337
+ description:
338
+ "Import a HyperFrames project (HeyGen's HTML-to-video format) into the attached tab: `path` is its folder (index.html at the top) or a .zip of it. The whole project becomes one code layer — nested data-composition-src files, pictures, videos, fonts and stylesheets embedded, HyperFrames' runtime driven by the playhead — and each <audio> clip a sound layer at its start. Then look at it with frames, and finish it by hand or with run_tool.",
339
+ inputSchema: { properties: { name: { type: "string" }, path: { type: "string" } }, required: ["path"], type: "object" },
340
+ name: "import_hyperframes",
341
+ run: async ({ name, path }) => {
342
+ const project = readParaplexProject(path);
343
+ const result = await tab.call("importHyperFrames", { ...project, ...(name ? { name } : {}) });
344
+
345
+ if (!result?.ok) {
346
+ throw new Error(result?.message ?? "The tab did not import it.");
347
+ }
348
+
349
+ return { message: result.message, missing: result.missing ?? [], scene: await tab.call("scene") };
350
+ },
351
+ },
352
+ {
353
+ description: "Save the open scene as a project of that browser's, by name.",
354
+ inputSchema: { properties: { name: { type: "string" } }, required: ["name"], type: "object" },
355
+ name: "save_project",
356
+ run: async ({ name }) => `Saved as project ${await tab.call("saveProject", { name })}.`,
357
+ },
358
+ {
359
+ description: "The review's facts at a time: where every layer lands on the frame (NDC boxes, ±1 is the frame's edge), each text's size on the frame in px and its fill, the camera.",
360
+ inputSchema: { properties: { seconds: { type: "number" } }, required: ["seconds"], type: "object" },
361
+ name: "scene_facts",
362
+ run: ({ seconds }) => tab.call("sceneFacts", { seconds }),
363
+ },
364
+ ];
365
+ }
@@ -0,0 +1,181 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+
4
+ /**
5
+ * A small WebSocket server (RFC 6455) over Node's own http and crypto, for
6
+ * the agent bridge's attach mode (roadmap v11, wave 5): one text message
7
+ * at a time each way, the client's frames masked as browsers send them,
8
+ * fragments joined, pings answered, a close closed. No extension, no
9
+ * binary messages — the bridge speaks JSON. Listens on 127.0.0.1 only, and
10
+ * refuses a handshake whose Origin the caller does not allow.
11
+ */
12
+
13
+ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
14
+ /** The largest message taken: a 4K export as a data URL fits with room. */
15
+ const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
16
+
17
+ function frame(opcode, payload) {
18
+ const length = payload.length;
19
+ const head = length < 126 ? Buffer.alloc(2) : length < 65536 ? Buffer.alloc(4) : Buffer.alloc(10);
20
+
21
+ head[0] = 0x80 | opcode;
22
+
23
+ if (length < 126) {
24
+ head[1] = length;
25
+ } else if (length < 65536) {
26
+ head[1] = 126;
27
+ head.writeUInt16BE(length, 2);
28
+ } else {
29
+ head[1] = 127;
30
+ head.writeBigUInt64BE(BigInt(length), 2);
31
+ }
32
+
33
+ return Buffer.concat([head, payload]);
34
+ }
35
+
36
+ /** One connection: `send(text)`, `close()`, and `onMessage` / `onClose` to set. */
37
+ function connection(socket) {
38
+ let buffer = Buffer.alloc(0);
39
+ let fragments = [];
40
+ let closed = false;
41
+ const peer = {
42
+ close() {
43
+ if (!closed) {
44
+ closed = true;
45
+ socket.end(frame(0x8, Buffer.alloc(0)));
46
+ }
47
+ },
48
+ onClose: () => {},
49
+ onMessage: () => {},
50
+ send(text) {
51
+ if (!closed) {
52
+ socket.write(frame(0x1, Buffer.from(text, "utf8")));
53
+ }
54
+ },
55
+ };
56
+
57
+ socket.on("data", (chunk) => {
58
+ buffer = Buffer.concat([buffer, chunk]);
59
+
60
+ for (;;) {
61
+ if (buffer.length < 2) {
62
+ return;
63
+ }
64
+
65
+ const fin = (buffer[0] & 0x80) !== 0;
66
+ const opcode = buffer[0] & 0x0f;
67
+ const masked = (buffer[1] & 0x80) !== 0;
68
+ let length = buffer[1] & 0x7f;
69
+ let offset = 2;
70
+
71
+ if (length === 126) {
72
+ if (buffer.length < 4) {
73
+ return;
74
+ }
75
+
76
+ length = buffer.readUInt16BE(2);
77
+ offset = 4;
78
+ } else if (length === 127) {
79
+ if (buffer.length < 10) {
80
+ return;
81
+ }
82
+
83
+ length = Number(buffer.readBigUInt64BE(2));
84
+ offset = 10;
85
+ }
86
+
87
+ if (length > MAX_MESSAGE_BYTES) {
88
+ peer.close();
89
+ socket.destroy();
90
+ return;
91
+ }
92
+
93
+ const maskOffset = offset;
94
+
95
+ if (masked) {
96
+ offset += 4;
97
+ }
98
+
99
+ if (buffer.length < offset + length) {
100
+ return;
101
+ }
102
+
103
+ const payload = Buffer.from(buffer.subarray(offset, offset + length));
104
+
105
+ if (masked) {
106
+ for (let index = 0; index < payload.length; index += 1) {
107
+ payload[index] ^= buffer[maskOffset + (index % 4)];
108
+ }
109
+ }
110
+
111
+ buffer = buffer.subarray(offset + length);
112
+
113
+ if (opcode === 0x8) {
114
+ peer.close();
115
+ return;
116
+ }
117
+
118
+ if (opcode === 0x9) {
119
+ socket.write(frame(0xa, payload));
120
+ continue;
121
+ }
122
+
123
+ if (opcode === 0xa) {
124
+ continue;
125
+ }
126
+
127
+ // Text, or a continuation of it; binary is not spoken here.
128
+ if (opcode === 0x1 || opcode === 0x0) {
129
+ fragments.push(payload);
130
+
131
+ if (fin) {
132
+ const text = Buffer.concat(fragments).toString("utf8");
133
+
134
+ fragments = [];
135
+ peer.onMessage(text);
136
+ }
137
+ }
138
+ }
139
+ });
140
+ socket.on("close", () => {
141
+ closed = true;
142
+ peer.onClose();
143
+ });
144
+ socket.on("error", () => {
145
+ closed = true;
146
+ });
147
+
148
+ return peer;
149
+ }
150
+
151
+ /**
152
+ * Listens on 127.0.0.1:`port`; each accepted connection is handed to
153
+ * `onConnection(peer, request)`. A handshake whose Origin `allowOrigin`
154
+ * refuses gets a 403 and no socket.
155
+ */
156
+ export function serveWebSocket({ allowOrigin, onConnection, port }) {
157
+ const server = createServer((_request, response) => {
158
+ response.writeHead(426, { "content-type": "text/plain" });
159
+ response.end("A WebSocket endpoint.");
160
+ });
161
+
162
+ server.on("upgrade", (request, socket) => {
163
+ const key = request.headers["sec-websocket-key"];
164
+ const origin = request.headers.origin ?? "";
165
+
166
+ if (typeof key !== "string" || (request.headers.upgrade ?? "").toLowerCase() !== "websocket" || !allowOrigin(origin)) {
167
+ socket.end("HTTP/1.1 403 Forbidden\r\n\r\n");
168
+ return;
169
+ }
170
+
171
+ const accept = createHash("sha1").update(key + GUID).digest("base64");
172
+
173
+ socket.write(["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", ""].join("\r\n"));
174
+ onConnection(connection(socket), request);
175
+ });
176
+
177
+ return new Promise((resolve, reject) => {
178
+ server.once("error", reject);
179
+ server.listen(port, "127.0.0.1", () => resolve(server));
180
+ });
181
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "paraplex-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "mcpName": "io.github.Paraplex-AI/paraplex-mcp",
5
+ "description": "Connect Claude Code (or any MCP client) to the Paraplex tab you have open: build and edit 3D motion design scenes with the assistant's tools, look at frames, export.",
6
+ "keywords": [
7
+ "mcp",
8
+ "model-context-protocol",
9
+ "claude",
10
+ "claude-code",
11
+ "paraplex",
12
+ "motion design",
13
+ "3d",
14
+ "animation"
15
+ ],
16
+ "homepage": "https://paraplex.ai",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Paraplex-AI/paraplex-mcp.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Paraplex Digital, LLC <info@paraplex.ai>",
23
+ "type": "module",
24
+ "bin": {
25
+ "paraplex-mcp": "bin.mjs"
26
+ },
27
+ "files": [
28
+ "bin.mjs",
29
+ "lib/",
30
+ "skills/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "scripts": {
38
+ "prepack": "node ../../scripts/build-mcp-package.mjs"
39
+ }
40
+ }
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: paraplex
3
+ description: Make or change a motion piece in the person's own open Paraplex tab from Claude Code — attach through the Paraplex MCP server (attach mode), build with the assistant's verbs (build_scene for a whole piece), look at frames as you go, and export. Use when the person asks for a video, a promo, a title sequence, a UI animation or any Paraplex scene to be made or edited from the terminal.
4
+ ---
5
+
6
+ # Paraplex from Claude Code
7
+
8
+ Paraplex is a browser editor for motion design: 3D panels, type, particles,
9
+ formations, generators, code layers, a keyframe timeline, and exports. The
10
+ Paraplex MCP server in **attach mode** drives the tab the person already has
11
+ open — their scene, their undo — rather than an editor of its own.
12
+
13
+ ## Connect
14
+
15
+ 1. The server is registered once (it waits on this computer for the
16
+ person's tab; nothing is installed in the browser):
17
+ `claude mcp add paraplex -- npx -y paraplex-mcp`
18
+ (Working in the Paraplex repo itself, `npm run agent --silent -- attach`
19
+ is the same server.)
20
+ 2. Call `connection`. Until a tab is attached it gives a port and a
21
+ one-time code: tell the person, word for word, to open Paraplex and go to
22
+ **Menu → Connect an agent**, then enter that port and code. A banner in
23
+ their tab shows while you are connected; they can disconnect any time.
24
+ 3. Call `scene` to see what is there before changing anything.
25
+
26
+ Everything you run lands in their tab, each verb one undo step for them. Say
27
+ what you changed after each step; never clear their scene without asking.
28
+
29
+ ## Build
30
+
31
+ - `list_tools` gives every verb with its schema; `run_tool` runs one.
32
+ - A whole piece in one call: `run_tool` with `build_scene` — each layer the
33
+ fields of its add verb plus `props`, `keys` (`[seconds, value, easing]`,
34
+ the easing arriving at the key), `clip`, `children` and `then` (any other
35
+ verb, `$id` naming another layer). Read its `warnings`: what could not be
36
+ built is skipped and said.
37
+ - Small changes: the verbs themselves (`set_property`, `set_keyframes`,
38
+ `add_camera_move`, `set_text_animation`, `set_layout` …), addressed by the
39
+ layer names the spec gave.
40
+ - Procedural geometry: `add_generator` (a starter or your own module);
41
+ many cards or dots in one shape: `add_formation` + `key_formation`; live
42
+ HTML or a React component: `write_code_layer`.
43
+ - A HyperFrames project you wrote (or the person has): `import_hyperframes`
44
+ with its folder or .zip. The whole project becomes one code layer —
45
+ nested compositions, media and HyperFrames' runtime driven by the
46
+ playhead — and each `<audio>` clip a sound layer at its start. Then build
47
+ around it: Paraplex layers over or behind it, camera moves, effects.
48
+ - A Remotion composition: `write_code_layer` with the one file (component
49
+ and `<Composition>` together). Its `remotion` import is Paraplex's own
50
+ version on the layer's clock — `useCurrentFrame`, `Sequence`, `Series`,
51
+ `interpolate`, `spring`, `@remotion/google-fonts` — and `<Composition>`
52
+ sets the layer's size and length. `<Audio>` is silent: add sound with
53
+ the audio verbs; `staticFile` has no folder: use web addresses.
54
+
55
+ ## Look, then fix
56
+
57
+ After every section, look: `frames` (one picture per time), `frame` at a
58
+ moment, `scene_facts` (where each layer lands on the frame, type sizes, the
59
+ camera). Check frame fit (nothing the piece is about half out of frame at a
60
+ hold), type (a caption ≥ 14 px on the frame, contrast ≥ 3:1), motion (one
61
+ ease per move, holds long enough to read) — fix what you see, then look
62
+ again. Work section by section for anything longer than a few seconds: plan
63
+ the sections, block a storyboard with `build_scene`, show the person the
64
+ frames, and build once they agree.
65
+
66
+ ## Deliver
67
+
68
+ `export_png` returns a still; `export_video` writes the file (in
69
+ `./paraplex-exports`, where the server was started) and says where.
70
+ `save_project` saves the scene in their browser by name.