paraplex-mcp 0.2.1 → 0.3.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/README.md +12 -4
- package/lib/paraplex-tab.mjs +282 -22
- package/package.json +1 -1
- package/skills/paraplex/SKILL.md +28 -8
package/README.md
CHANGED
|
@@ -30,9 +30,16 @@ time.
|
|
|
30
30
|
| `list_templates`, `apply_template`, `new_project` | Start from a template or a blank file |
|
|
31
31
|
| `set_time`, `frame`, `frames`, `export_png` | Look at the piece at any time, as its export draws it |
|
|
32
32
|
| `scene_facts` | Where every layer lands in the frame, type sizes on the frame, the camera |
|
|
33
|
-
| `
|
|
33
|
+
| `export` | Export to a file on this computer (`./paraplex-exports`, or `PARAPLEX_OUT_DIR`) with any of the Export section's options for that export only: image or video, format, scale, frame rate, part of the timeline, no background, one artboard or all, App Store / Keynote / PowerPoint / Google Slides presets. Large files stream in pieces, so there is no size ceiling |
|
|
34
|
+
| `render_variants` | A render per row of data (rows or CSV), each column filling the layer of its name |
|
|
35
|
+
| `export_video` | The video at the scene's own settings (kept from 0.2) |
|
|
36
|
+
| `import_file` | Send a file from this computer into the scene, as a drop takes it: a picture or video, sound, a 3D model, Lottie, Figma frames, an SVG, a font |
|
|
34
37
|
| `import_hyperframes` | Bring in a HyperFrames project from a folder or .zip on this computer: one code layer, its sound on the timeline |
|
|
35
|
-
| `
|
|
38
|
+
| `undo`, `redo` | Step back or forward, as ⌘Z does |
|
|
39
|
+
| `review_sheet`, `review_artboards` | Frames across the piece as one sheet, with what runs past the frame, small or low-contrast type, and motion hitches; or the piece on 9:16, 4:5, 1:1 and 16:9 |
|
|
40
|
+
| `list_projects`, `open_project`, `save_project` | Your projects in this browser and your account; open one, or save the scene as one |
|
|
41
|
+
| `list_design_systems`, `apply_design_system` | Paraplex's design systems and yours; apply one in a single undo step |
|
|
42
|
+
| `list_skills`, `load_skill`, `read_skill_file`, `write_skill` | The assistant's skills: read them, or write one into your library |
|
|
36
43
|
|
|
37
44
|
Every edit lands in your tab as one undo step, so you can step back through
|
|
38
45
|
anything the agent did.
|
|
@@ -42,8 +49,9 @@ anything the agent did.
|
|
|
42
49
|
- The server listens on this computer only (port 47821; pass another as
|
|
43
50
|
`npx paraplex-mcp 48000`).
|
|
44
51
|
- 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,
|
|
46
|
-
written where you ran the server
|
|
52
|
+
- Nothing is sent anywhere else: the scene stays in your tab, exports are
|
|
53
|
+
written where you ran the server, and a file you send in goes only to your
|
|
54
|
+
tab. An agent reaches only what you are signed in to in that tab.
|
|
47
55
|
- AI used inside Paraplex is metered by your Paraplex plan as usual; the agent's
|
|
48
56
|
own model is your Claude Code's.
|
|
49
57
|
|
package/lib/paraplex-tab.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { randomInt } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readdirSync, readFileSync, statSync,
|
|
3
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
1
|
+
import { randomInt, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { imageContent } from "./mcp.mjs";
|
|
6
6
|
import { serveWebSocket } from "./ws-server.mjs";
|
|
@@ -18,6 +18,35 @@ import { serveWebSocket } from "./ws-server.mjs";
|
|
|
18
18
|
|
|
19
19
|
export const PARAPLEX_ATTACH_PORT = 47821;
|
|
20
20
|
|
|
21
|
+
/** A piece of a file each way (roadmap v22 W5): 768 KB, about 1 MB as base64. */
|
|
22
|
+
export const PARAPLEX_CHUNK_BYTES = 768 * 1024;
|
|
23
|
+
|
|
24
|
+
/** A path in `dir` for `fileName` that is not taken: "name.mp4", then "name-2.mp4". */
|
|
25
|
+
export function paraplexFreePath(dir, fileName) {
|
|
26
|
+
const safe = basename(String(fileName || "paraplex-export")).replace(/[\\/:*?"<>|]+/g, "-");
|
|
27
|
+
const extension = extname(safe);
|
|
28
|
+
const stem = safe.slice(0, safe.length - extension.length);
|
|
29
|
+
let candidate = join(dir, safe);
|
|
30
|
+
|
|
31
|
+
for (let index = 2; existsSync(candidate); index += 1) {
|
|
32
|
+
candidate = join(dir, `${stem}-${index}${extension}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a file's extension says it is, for the tab's import. */
|
|
39
|
+
export function paraplexMimeType(path) {
|
|
40
|
+
const types = {
|
|
41
|
+
".aac": "audio/aac", ".avif": "image/avif", ".csv": "text/csv", ".flac": "audio/flac", ".gif": "image/gif", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json",
|
|
42
|
+
".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".json": "application/json", ".lottie": "application/zip", ".m4a": "audio/mp4", ".mov": "video/quicktime", ".mp3": "audio/mpeg",
|
|
43
|
+
".mp4": "video/mp4", ".ogg": "audio/ogg", ".otf": "font/otf", ".png": "image/png", ".ply": "application/octet-stream", ".splat": "application/octet-stream", ".spz": "application/octet-stream",
|
|
44
|
+
".svg": "image/svg+xml", ".ttf": "font/ttf", ".wav": "audio/wav", ".webm": "video/webm", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return types[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
/** What a project folder may send the tab: past this, its media will not fit a layer anyway. */
|
|
22
51
|
const PROJECT_MAX_BYTES = 96 * 1024 * 1024;
|
|
23
52
|
const PROJECT_MAX_FILES = 4000;
|
|
@@ -131,13 +160,40 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
|
|
|
131
160
|
}
|
|
132
161
|
|
|
133
162
|
waiters = [];
|
|
163
|
+
} else if (data.type === "file" && pending.has(data.id)) {
|
|
164
|
+
// A file the call sends back, in pieces: written as they come.
|
|
165
|
+
const entry = pending.get(data.id);
|
|
166
|
+
const dir = entry.into ?? join(process.cwd(), "paraplex-exports");
|
|
167
|
+
|
|
168
|
+
mkdirSync(dir, { recursive: true });
|
|
169
|
+
|
|
170
|
+
const path = paraplexFreePath(dir, data.fileName);
|
|
171
|
+
|
|
172
|
+
entry.files.set(data.file, { bytes: 0, fd: openSync(path, "w"), path });
|
|
173
|
+
} else if (data.type === "chunk" && pending.has(data.id)) {
|
|
174
|
+
const file = pending.get(data.id).files.get(data.file);
|
|
175
|
+
|
|
176
|
+
if (file && typeof data.data === "string") {
|
|
177
|
+
file.bytes += writeSync(file.fd, Buffer.from(data.data, "base64"));
|
|
178
|
+
}
|
|
134
179
|
} else if (data.type === "result" && pending.has(data.id)) {
|
|
135
|
-
const { reject, resolve } = pending.get(data.id);
|
|
180
|
+
const { files, reject, resolve } = pending.get(data.id);
|
|
136
181
|
|
|
137
182
|
pending.delete(data.id);
|
|
138
183
|
|
|
184
|
+
for (const file of files.values()) {
|
|
185
|
+
closeSync(file.fd);
|
|
186
|
+
}
|
|
187
|
+
|
|
139
188
|
if (data.ok) {
|
|
140
|
-
|
|
189
|
+
// A streamed file's number in the answer becomes where it was written.
|
|
190
|
+
const value = data.value;
|
|
191
|
+
|
|
192
|
+
if (value && Array.isArray(value.files) && files.size > 0) {
|
|
193
|
+
resolve({ ...value, files: value.files.map((entry) => (files.has(entry.file) ? { ...entry, bytes: files.get(entry.file).bytes, path: files.get(entry.file).path } : entry)) });
|
|
194
|
+
} else {
|
|
195
|
+
resolve(value);
|
|
196
|
+
}
|
|
141
197
|
} else {
|
|
142
198
|
reject(new Error(data.error ?? "The tab could not do that."));
|
|
143
199
|
}
|
|
@@ -147,7 +203,11 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
|
|
|
147
203
|
if (tab === peer) {
|
|
148
204
|
tab = null;
|
|
149
205
|
|
|
150
|
-
for (const { reject } of pending.values()) {
|
|
206
|
+
for (const { files, reject } of pending.values()) {
|
|
207
|
+
for (const file of files.values()) {
|
|
208
|
+
closeSync(file.fd);
|
|
209
|
+
}
|
|
210
|
+
|
|
151
211
|
reject(new Error("The tab disconnected."));
|
|
152
212
|
}
|
|
153
213
|
|
|
@@ -163,7 +223,7 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
|
|
|
163
223
|
|
|
164
224
|
log(`Waiting for a Paraplex tab: in Paraplex, Menu → Connect an agent, port ${listening}, code ${code}.`);
|
|
165
225
|
|
|
166
|
-
const
|
|
226
|
+
const waitForTab = async () => {
|
|
167
227
|
if (!tab) {
|
|
168
228
|
await new Promise((resolve, reject) => {
|
|
169
229
|
const timer = setTimeout(() => reject(new Error(`No Paraplex tab is attached. In Paraplex: Menu → Connect an agent, port ${listening}, code ${code}.`)), 180_000);
|
|
@@ -174,6 +234,43 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
|
|
|
174
234
|
});
|
|
175
235
|
});
|
|
176
236
|
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Sends a local file to the tab in pieces, ahead of the call that uses
|
|
241
|
+
* it; resolves with its upload id.
|
|
242
|
+
*/
|
|
243
|
+
const upload = async (path) => {
|
|
244
|
+
await waitForTab();
|
|
245
|
+
|
|
246
|
+
const uploadId = randomUUID();
|
|
247
|
+
const mimeType = paraplexMimeType(path);
|
|
248
|
+
const fd = openSync(path, "r");
|
|
249
|
+
const buffer = Buffer.alloc(PARAPLEX_CHUNK_BYTES);
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
for (;;) {
|
|
253
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
254
|
+
|
|
255
|
+
if (read <= 0) {
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
tab.send(JSON.stringify({ data: buffer.subarray(0, read).toString("base64"), mimeType, type: "upload", upload: uploadId }));
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
closeSync(fd);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// An empty file still arrives, as nothing.
|
|
266
|
+
tab.send(JSON.stringify({ mimeType, type: "upload", upload: uploadId }));
|
|
267
|
+
|
|
268
|
+
return uploadId;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** A call to the tab; `into` is where files it streams back are written. */
|
|
272
|
+
const call = async (method, params = {}, { into } = {}) => {
|
|
273
|
+
await waitForTab();
|
|
177
274
|
|
|
178
275
|
const id = next++;
|
|
179
276
|
|
|
@@ -185,6 +282,8 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
|
|
|
185
282
|
}, 600_000);
|
|
186
283
|
|
|
187
284
|
pending.set(id, {
|
|
285
|
+
files: new Map(),
|
|
286
|
+
into,
|
|
188
287
|
reject: (error) => {
|
|
189
288
|
clearTimeout(timer);
|
|
190
289
|
reject(error);
|
|
@@ -201,6 +300,7 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
|
|
|
201
300
|
return {
|
|
202
301
|
attached: () => tab !== null,
|
|
203
302
|
call,
|
|
303
|
+
upload,
|
|
204
304
|
close: () => new Promise((resolve) => {
|
|
205
305
|
tab?.close();
|
|
206
306
|
server.close(() => resolve());
|
|
@@ -210,6 +310,28 @@ export async function attachParaplexTab({ allowOrigin = paraplexOriginAllowed, c
|
|
|
210
310
|
};
|
|
211
311
|
}
|
|
212
312
|
|
|
313
|
+
/**
|
|
314
|
+
* An export through the tab, its files streamed into `path` (a folder, or
|
|
315
|
+
* a file name when one file comes back) or the export folder; answers with
|
|
316
|
+
* each file, where it went and its size.
|
|
317
|
+
*/
|
|
318
|
+
export async function exportToDisk(tab, options, { outDir, path }) {
|
|
319
|
+
const asFile = typeof path === "string" && extname(path) !== "" && !(existsSync(path) && statSync(path).isDirectory());
|
|
320
|
+
const into = path ? (asFile ? dirname(resolve(path)) : resolve(path)) : outDir;
|
|
321
|
+
const result = await tab.call("exportFiles", options, { into });
|
|
322
|
+
const files = Array.isArray(result?.files) ? result.files : [];
|
|
323
|
+
|
|
324
|
+
if (asFile && files.length === 1 && files[0].path && resolve(path) !== files[0].path) {
|
|
325
|
+
renameSync(files[0].path, resolve(path));
|
|
326
|
+
files[0].path = resolve(path);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
files: files.map(({ bytes, fileName, notice, path: written }) => ({ bytes, fileName, ...(notice ? { notice } : {}), path: written })),
|
|
331
|
+
written: files.map((file) => file.path).join(", "),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
213
335
|
/** The MCP tools over an attached tab: the headless server's, as far as named calls reach. */
|
|
214
336
|
export function attachedTabTools(tab, { outDir }) {
|
|
215
337
|
return [
|
|
@@ -315,23 +437,53 @@ export function attachedTabTools(tab, { outDir }) {
|
|
|
315
437
|
},
|
|
316
438
|
},
|
|
317
439
|
{
|
|
318
|
-
description:
|
|
440
|
+
description:
|
|
441
|
+
"Export with any of the Export section's options, the files written on this computer as the tab renders them (no size limit): an image (png, jpg) at `seconds`, or a video (mp4, webm, gif, png-sequence) over `inSeconds`–`outSeconds`; `scale` 0.5–4, `fps`, `background: false` for transparency (PNG, WebM, PNG sequence); `artboard` an artboard's id or label, or \"all\" for one file each; `deliver` a destination's preset (app-store with `device` iphone/ipad/mac, keynote, powerpoint, google-slides). Only what you pass changes, and only for this export: the person's settings stay. `path` is a folder, or a file name for a single file (default ./paraplex-exports). A Free account gets the reason a Pro export is refused.",
|
|
442
|
+
inputSchema: {
|
|
443
|
+
properties: {
|
|
444
|
+
artboard: { type: "string" },
|
|
445
|
+
background: { type: "boolean" },
|
|
446
|
+
deliver: { enum: ["app-store", "keynote", "powerpoint", "google-slides"], type: "string" },
|
|
447
|
+
device: { enum: ["iphone", "ipad", "mac"], type: "string" },
|
|
448
|
+
format: { enum: ["png", "jpg", "mp4", "webm", "gif", "png-sequence"], type: "string" },
|
|
449
|
+
fps: { type: "number" },
|
|
450
|
+
inSeconds: { type: "number" },
|
|
451
|
+
kind: { enum: ["image", "video"], type: "string" },
|
|
452
|
+
outSeconds: { type: "number" },
|
|
453
|
+
path: { type: "string" },
|
|
454
|
+
scale: { type: "number" },
|
|
455
|
+
seconds: { type: "number" },
|
|
456
|
+
},
|
|
457
|
+
type: "object",
|
|
458
|
+
},
|
|
459
|
+
name: "export",
|
|
460
|
+
run: async ({ path, ...options }) => exportToDisk(tab, options, { outDir, path }),
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
description:
|
|
464
|
+
"Render a variant per row, as Variants does: `rows` (objects) or `csv` (a header row, then a row per variant). Each column fills the layer named like it — its text, a counter's end value, a fill color, or a picture by its file name in the scene's media library — or the layer `bindings` maps it to (column → layer id or name). `nameColumn` names each file. Takes every option `export` takes (an image by default; kind video for a clip per row). Without rows, the sheet already set in Variants. Answers with the files and any column that bound to nothing.",
|
|
465
|
+
inputSchema: {
|
|
466
|
+
properties: {
|
|
467
|
+
bindings: { additionalProperties: { type: "string" }, type: "object" },
|
|
468
|
+
csv: { type: "string" },
|
|
469
|
+
format: { enum: ["png", "jpg", "mp4", "webm", "gif", "png-sequence"], type: "string" },
|
|
470
|
+
kind: { enum: ["image", "video"], type: "string" },
|
|
471
|
+
nameColumn: { type: "string" },
|
|
472
|
+
options: { description: "Any other export option: scale, fps, seconds, inSeconds, outSeconds, background, artboard.", type: "object" },
|
|
473
|
+
path: { type: "string" },
|
|
474
|
+
rows: { items: { type: "object" }, type: "array" },
|
|
475
|
+
},
|
|
476
|
+
type: "object",
|
|
477
|
+
},
|
|
478
|
+
name: "render_variants",
|
|
479
|
+
run: async ({ bindings, csv, format, kind, nameColumn, options, path, rows }) =>
|
|
480
|
+
exportToDisk(tab, { ...(options ?? {}), ...(format ? { format } : {}), ...(kind ? { kind } : {}), variants: { ...(bindings ? { bindings } : {}), ...(csv !== undefined ? { csv } : {}), ...(nameColumn ? { nameColumn } : {}), ...(rows ? { rows } : {}) } }, { outDir, path: path ?? join(outDir, "variants") }),
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
description: "Export the scene as a video with its own settings, written to a file in the export folder (or at `path`); answers with where it went. The `export` tool takes every option.",
|
|
319
484
|
inputSchema: { properties: { path: { type: "string" } }, type: "object" },
|
|
320
485
|
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
|
-
},
|
|
486
|
+
run: async ({ path }) => exportToDisk(tab, { kind: "video" }, { outDir, path }),
|
|
335
487
|
},
|
|
336
488
|
{
|
|
337
489
|
description:
|
|
@@ -349,6 +501,114 @@ export function attachedTabTools(tab, { outDir }) {
|
|
|
349
501
|
return { message: result.message, missing: result.missing ?? [], scene: await tab.call("scene") };
|
|
350
502
|
},
|
|
351
503
|
},
|
|
504
|
+
{
|
|
505
|
+
description:
|
|
506
|
+
"Bring a file from this computer into the open scene, as dropping it on the canvas does: a picture or video becomes a layer (or fills the layer `target`, by id — a card, a phone screen, a component's slot), sound a clip at the playhead, a GLB/glTF/splat a model, a .lottie or Lottie .json an animation, an SVG a vector, a TTF/OTF/WOFF a font of the scene's. Answers with what happened and the layers it added.",
|
|
507
|
+
inputSchema: { properties: { path: { type: "string" }, target: { type: "string" } }, required: ["path"], type: "object" },
|
|
508
|
+
name: "import_file",
|
|
509
|
+
run: async ({ path, target }) => {
|
|
510
|
+
const full = resolve(path);
|
|
511
|
+
|
|
512
|
+
if (!existsSync(full) || !statSync(full).isFile()) {
|
|
513
|
+
throw new Error(`No file at ${full}.`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const upload = await tab.upload(full);
|
|
517
|
+
|
|
518
|
+
return tab.call("importFile", { name: basename(full), upload, ...(target ? { target } : {}) });
|
|
519
|
+
},
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
description: "Undo the last `steps` edits in the tab (default 1), as ⌘Z does — a run_tool call is one step. Answers with how many it took and whether more remain.",
|
|
523
|
+
inputSchema: { properties: { steps: { type: "number" } }, type: "object" },
|
|
524
|
+
name: "undo",
|
|
525
|
+
run: ({ steps }) => tab.call("undo", { steps: steps ?? 1 }),
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
description: "Redo `steps` undone edits (default 1), as ⇧⌘Z does.",
|
|
529
|
+
inputSchema: { properties: { steps: { type: "number" } }, type: "object" },
|
|
530
|
+
name: "redo",
|
|
531
|
+
run: ({ steps }) => tab.call("redo", { steps: steps ?? 1 }),
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
description:
|
|
535
|
+
"A review sheet of the open scene, the way to judge a piece before calling it done: frames across it (default eight; or `times`) as one picture, and the flags — layers past the frame (more than 0.05 of the frame), text under 14 px on the frame, text under 3:1 contrast against what is behind it, motion hitches (a speed that jumps or a path that turns a corner mid-move). Fix what is flagged unless it is a deliberate choice you can name.",
|
|
536
|
+
inputSchema: { properties: { times: { items: { type: "number" }, type: "array" }, width: { type: "number" } }, type: "object" },
|
|
537
|
+
name: "review_sheet",
|
|
538
|
+
run: async ({ times, width }) => {
|
|
539
|
+
const review = await tab.call("review", { ...(times ? { times } : {}), ...(width ? { width } : {}) });
|
|
540
|
+
|
|
541
|
+
if (!review) {
|
|
542
|
+
return "No editor.";
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const { sheet, ...facts } = review;
|
|
546
|
+
|
|
547
|
+
return [...imageContent(sheet, "The frames, left to right, top to bottom."), { text: JSON.stringify(facts, null, 2), type: "text" }];
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
description:
|
|
552
|
+
"The piece on every review artboard (9:16, 4:5, 1:1, 16:9) as Responsive lays it out, at `times`: what each artboard did, what its frame cuts, the smallest type and type at the edge, with all the frames as one picture.",
|
|
553
|
+
inputSchema: { properties: { times: { items: { type: "number" }, type: "array" } }, type: "object" },
|
|
554
|
+
name: "review_artboards",
|
|
555
|
+
run: async ({ times }) => {
|
|
556
|
+
const scene = await tab.call("scene");
|
|
557
|
+
const moments = times?.length ? times : [0.25, 0.5, 0.9].map((share) => Number(((scene?.durationSeconds ?? 8) * share).toFixed(2)));
|
|
558
|
+
const review = await tab.call("reviewArtboards", { times: moments });
|
|
559
|
+
|
|
560
|
+
if (!review) {
|
|
561
|
+
return "No editor.";
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return [...imageContent(review.sheet, "Each artboard at each time."), { text: JSON.stringify(review.reviews, null, 2), type: "text" }];
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
description: "The person's projects, newest first: those in this browser and, when the tab is signed in, those kept in their account (in the cloud), each with its id, name, when it changed, and which one is open.",
|
|
569
|
+
name: "list_projects",
|
|
570
|
+
run: () => tab.call("projects"),
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
description: "Open one of the person's projects by id (list_projects), as the editor opens it: the scene being left is saved first; a project kept only in the account is fetched. Answers with its name and its scene.",
|
|
574
|
+
inputSchema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" },
|
|
575
|
+
name: "open_project",
|
|
576
|
+
run: ({ id }) => tab.call("openProject", { id }),
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
description: "Design systems the person can apply: Paraplex's and their own (made, imported or installed), each with what it holds (colors, text styles, materials, looks, fonts), and the one the scene follows now.",
|
|
580
|
+
name: "list_design_systems",
|
|
581
|
+
run: () => tab.call("designSystems"),
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
description: "Apply a design system by id or name, as Use in project does: its colors, text styles, materials, looks and fonts merged into the scene, which then follows it. One undo step. Then build with its tokens (run_tool get_brand reads them).",
|
|
585
|
+
inputSchema: { properties: { id: { type: "string" } }, required: ["id"], type: "object" },
|
|
586
|
+
name: "apply_design_system",
|
|
587
|
+
run: ({ id }) => tab.call("applyDesignSystem", { id }),
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
description: "The person's skills and Paraplex's (Agent Skills: instructions and reference files), each with its description and whether it is on. Read one with load_skill when a task matches it.",
|
|
591
|
+
name: "list_skills",
|
|
592
|
+
run: () => tab.call("skills"),
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
description: "A skill's instructions (its SKILL.md body) and the files inside it, by name. Read it before doing the work it describes.",
|
|
596
|
+
inputSchema: { properties: { name: { type: "string" } }, required: ["name"], type: "object" },
|
|
597
|
+
name: "load_skill",
|
|
598
|
+
run: async ({ name }) => (await tab.call("loadSkill", { name })).content,
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
description: "One file inside a skill — a reference, a template, a checklist — by the path load_skill listed.",
|
|
602
|
+
inputSchema: { properties: { name: { type: "string" }, path: { type: "string" } }, required: ["name", "path"], type: "object" },
|
|
603
|
+
name: "read_skill_file",
|
|
604
|
+
run: async ({ name, path }) => (await tab.call("readSkillFile", { name, path })).content,
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
description: "Write a skill's SKILL.md into the person's library: the whole file, frontmatter (name, description) then instructions. Rewrites the named skill's SKILL.md and keeps its reference files, or creates it. Paraplex's own skills are not rewritten.",
|
|
608
|
+
inputSchema: { properties: { markdown: { type: "string" }, name: { type: "string" } }, required: ["name", "markdown"], type: "object" },
|
|
609
|
+
name: "write_skill",
|
|
610
|
+
run: ({ markdown, name }) => tab.call("writeSkill", { markdown, name }),
|
|
611
|
+
},
|
|
352
612
|
{
|
|
353
613
|
description: "Save the open scene as a project of that browser's, by name.",
|
|
354
614
|
inputSchema: { properties: { name: { type: "string" } }, required: ["name"], type: "object" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paraplex-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"mcpName": "ai.paraplex/paraplex-mcp",
|
|
5
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
6
|
"keywords": [
|
package/skills/paraplex/SKILL.md
CHANGED
|
@@ -56,15 +56,35 @@ what you changed after each step; never clear their scene without asking.
|
|
|
56
56
|
|
|
57
57
|
After every section, look: `frames` (one picture per time), `frame` at a
|
|
58
58
|
moment, `scene_facts` (where each layer lands on the frame, type sizes, the
|
|
59
|
-
camera).
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
camera). `review_sheet` does the checking for you: the frames as one sheet
|
|
60
|
+
and the flags — anything past the frame, type under 14 px, contrast under
|
|
61
|
+
3:1, motion hitches; `review_artboards` shows the piece on 9:16, 4:5, 1:1
|
|
62
|
+
and 16:9. Check frame fit (nothing the piece is about half out of frame at
|
|
63
|
+
a hold), type, motion (one ease per move, holds long enough to read) — fix
|
|
64
|
+
what you see, then look again. `undo` takes back a step that went wrong.
|
|
65
|
+
Work section by section for anything longer than a few seconds: plan the
|
|
66
|
+
sections, block a storyboard with `build_scene`, show the person the
|
|
64
67
|
frames, and build once they agree.
|
|
65
68
|
|
|
69
|
+
## Their library
|
|
70
|
+
|
|
71
|
+
- Files from this computer: `import_file` with a path (a picture, video,
|
|
72
|
+
sound, model, Lottie, Figma frames, SVG or font), optionally onto a
|
|
73
|
+
layer by id. Read the layers it says it added.
|
|
74
|
+
- Their projects: `list_projects`, `open_project` (ask before leaving a
|
|
75
|
+
scene they are working on), `save_project`.
|
|
76
|
+
- A brand: `list_design_systems`, `apply_design_system` by name.
|
|
77
|
+
- The assistant's skills: `list_skills`, `load_skill` before a kind of
|
|
78
|
+
piece a skill covers; `write_skill` saves one to their library.
|
|
79
|
+
|
|
66
80
|
## Deliver
|
|
67
81
|
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
`
|
|
82
|
+
`export` writes the file (in `./paraplex-exports`, where the server was
|
|
83
|
+
started) and says where; pass only the options the person asked for —
|
|
84
|
+
`kind`, `format` (png, jpg, mp4, webm, gif, png-sequence), `scale`, `fps`,
|
|
85
|
+
`inSeconds`/`outSeconds`, `background: false` for transparency, `artboard`
|
|
86
|
+
(a name, or `all`), `deliver` (app-store, keynote, powerpoint,
|
|
87
|
+
google-slides). Their own Export settings stay as they were; a Pro-only
|
|
88
|
+
export on Free comes back with the reason, as the Export button gives it.
|
|
89
|
+
`render_variants` makes a file per row of data. `export_png` returns a
|
|
90
|
+
still to look at.
|