voidless-mcp 1.1.0 → 1.6.2

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/index.js CHANGED
@@ -1,814 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Voidless MCP Server
3
+ * Voidless MCP Server (local, stdio).
4
4
  *
5
- * Exposes the Voidless API as MCP tools so any MCP client (Claude, Cursor, …) can find and tag a
6
- * user's assets and generate new ones: sprites, edits, 8-way rotations, single animations, whole
7
- * animation sets from presets, "keep motion" edits across animations, sound, music, video and
8
- * rigged 3D models.
5
+ * Exposes the Voidless API as MCP tools for clients that run a local server: Claude Desktop, Claude
6
+ * Code, Cursor, VS Code. The hosted connector at https://mcp.voidless.dev/mcp serves the same tools
7
+ * (tools.js) to clients that take a URL and sign in instead.
9
8
  *
10
9
  * Required environment variables:
11
10
  * VOIDLESS_API_KEY - void_... API key from your Voidless account (Voidless+)
12
- *
13
- * Request shapes follow the Strapi controllers in strapi/src/api/generate/controllers. Several
14
- * fields there are tested with `!== ""`, so an omitted field is NOT the same as an empty one:
15
- * referenceImage/styleImage/end_frame are always sent, empty when unused.
16
11
  */
17
12
 
18
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
13
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
- import { z } from "zod";
14
+ import { createVoidlessServer } from "./tools.js";
21
15
 
22
- const VERSION = "1.1.0";
23
- const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
24
- const CDN_URL = "https://cdn.voidless.dev";
25
- const APP_URL = "https://voidless.dev";
26
16
  const API_KEY = process.env.VOIDLESS_API_KEY || "";
27
-
28
- // Inline previews are capped so one call that returns 16 animations does not send 16 images.
29
- const MAX_INLINE_IMAGES = 4;
30
-
31
17
  if (!API_KEY) {
32
18
  console.error("[voidless-mcp] VOIDLESS_API_KEY is not set (expected a void_... key)");
33
19
  process.exit(1);
34
20
  }
35
21
 
36
- async function api(method, path, body) {
37
- const res = await fetch(`${BASE_URL}/api${path}`, {
38
- method,
39
- headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
40
- body: body === undefined ? undefined : JSON.stringify(body),
41
- });
42
-
43
- let data;
44
- const text = await res.text();
45
- try {
46
- data = JSON.parse(text);
47
- } catch {
48
- data = { raw: text };
49
- }
50
-
51
- if (!res.ok) {
52
- const msg = data?.error?.message || data?.message || data?.raw || `HTTP ${res.status}`;
53
- throw new Error(`Voidless API error (${res.status}): ${msg}`);
54
- }
55
- return data;
56
- }
57
-
58
- const apiGet = (path) => api("GET", path);
59
- const apiPost = (path, body) => api("POST", path, body);
60
- const apiPut = (path, body) => api("PUT", path, body);
61
-
62
- // Tool errors go back to the model as text it can act on, not as a protocol failure.
63
- function tool(handler) {
64
- return async (args) => {
65
- try {
66
- return await handler(args);
67
- } catch (err) {
68
- return { isError: true, content: [{ type: "text", text: String(err.message || err) }] };
69
- }
70
- };
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // Formatting
75
- // ---------------------------------------------------------------------------
76
-
77
- function mimeFromPath(filePath) {
78
- const ext = String(filePath || "").split(".").pop().toLowerCase();
79
- const map = {
80
- png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
81
- mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", mp4: "video/mp4", webm: "video/webm",
82
- glb: "model/gltf-binary", gltf: "model/gltf+json",
83
- };
84
- return map[ext] || "application/octet-stream";
85
- }
86
-
87
- // Stored keys are R2 paths (assets/<uid>/…) or Drive refs (gdrive:…), which have no public URL.
88
- function cdnUrl(key) {
89
- if (!key || typeof key !== "string") return null;
90
- if (/^https?:\/\//.test(key)) return key;
91
- if (key.startsWith("gdrive:")) return null;
92
- return `${CDN_URL}/${key.replace(/^\/+/, "")}`;
93
- }
94
-
95
- async function inlineImage(key) {
96
- const url = cdnUrl(key);
97
- if (!url || !mimeFromPath(url).startsWith("image/")) return null;
98
- try {
99
- const res = await fetch(url);
100
- if (!res.ok) return null;
101
- const data = Buffer.from(await res.arrayBuffer()).toString("base64");
102
- return { type: "image", data, mimeType: mimeFromPath(url) };
103
- } catch {
104
- return null;
105
- }
106
- }
107
-
108
- function money(data) {
109
- const parts = [];
110
- if (typeof data?.cost === "number") parts.push(`Cost: ${data.cost} credits`);
111
- if (typeof data?.credits === "number") parts.push(`Credits left: ${data.credits}`);
112
- return parts.join(" · ");
113
- }
114
-
115
- // A rotation frame's `tags` field is its facings list ("right,up,left…"), which animation presets
116
- // read to match each image to a direction. It is not user tagging, and must not be overwritten.
117
- const isRotation = (frame) => String(frame?.type || "").startsWith("rotations");
118
-
119
- // One line per frame, carrying everything a follow-up call needs: the frame id (frameIndex /
120
- // items / frameId elsewhere) and the image keys (referenceFile / end_frame / imageIndex).
121
- function frameLine(frame) {
122
- const images = Array.isArray(frame.images) ? frame.images : [];
123
- const bits = [`frame ${frame.id}`, frame.type || "frame", frame.name ? `"${frame.name}"` : null];
124
- if (frame.tags) bits.push(`${isRotation(frame) ? "facings" : "tags"}: ${frame.tags}`);
125
- const imageText =
126
- images.length <= 8
127
- ? images.map((k, i) => ` [${i}] ${k}`).join("\n")
128
- : ` ${images.length} images, first ${images[0]}, last ${images[images.length - 1]}`;
129
- const gif = frame.gif ? `\n preview: ${cdnUrl(frame.gif) || frame.gif}` : "";
130
- return ` - ${bits.filter(Boolean).join(" · ")}${gif}${images.length ? "\n" + imageText : ""}`;
131
- }
132
-
133
- async function frameContent(frames, { inline = true } = {}) {
134
- const content = [];
135
- if (!frames?.length) return content;
136
- content.push({ type: "text", text: "New frames:\n" + frames.map(frameLine).join("\n") });
137
- if (inline) {
138
- for (const frame of frames.slice(0, MAX_INLINE_IMAGES)) {
139
- const image = await inlineImage(frame.gif || frame.images?.[0]);
140
- if (image) content.push(image);
141
- }
142
- }
143
- return content;
144
- }
145
-
146
- // Responses that create a new asset: { file, assetId, credits, cost, … }.
147
- async function assetResult(data, label) {
148
- const url = cdnUrl(data.file);
149
- const lines = [
150
- `${label} created.`,
151
- data.assetId ? `Asset ID: ${data.assetId} · ${APP_URL}/asset/${data.assetId}` : null,
152
- url && !mimeFromPath(url).startsWith("image/") ? `File: ${url}` : null,
153
- data.thumbnail ? `Thumbnail: ${cdnUrl(data.thumbnail)}` : null,
154
- data.seed !== undefined ? `Seed: ${data.seed}` : null,
155
- money(data),
156
- ].filter(Boolean);
157
- const content = [{ type: "text", text: lines.join("\n") }];
158
- const image = await inlineImage(data.file);
159
- if (image) content.push(image);
160
- return { content };
161
- }
162
-
163
- // Responses that append frames to an existing asset: { frames, credits, cost }.
164
- async function framesResult(data, assetId, label) {
165
- const content = [
166
- {
167
- type: "text",
168
- text: [`${label} added to asset ${assetId} (${APP_URL}/asset/${assetId}).`, money(data)]
169
- .filter(Boolean)
170
- .join("\n"),
171
- },
172
- ];
173
- content.push(...(await frameContent(data.frames)));
174
- if (Array.isArray(data.missing) && data.missing.length) {
175
- content.push({ type: "text", text: `Skipped (could not be edited): ${JSON.stringify(data.missing)}` });
176
- }
177
- return { content };
178
- }
179
-
180
- // Multi-animation responses: { results: [{ assetId, frames, skipped? }], quality, cost, … }.
181
- async function resultsContent(data, label) {
182
- const content = [
183
- {
184
- type: "text",
185
- text: [label, data.quality ? `Quality: ${data.quality}` : null, money(data)].filter(Boolean).join("\n"),
186
- },
187
- ];
188
- let inlined = 0;
189
- for (const result of data.results || []) {
190
- if (result.skipped) {
191
- content.push({ type: "text", text: `Asset ${result.assetId}: skipped${result.error ? ` (${result.error})` : ""}` });
192
- continue;
193
- }
194
- content.push({
195
- type: "text",
196
- text: `Asset ${result.assetId} (${APP_URL}/asset/${result.assetId}):\n` + (result.frames || []).map(frameLine).join("\n"),
197
- });
198
- for (const frame of result.frames || []) {
199
- if (inlined >= MAX_INLINE_IMAGES) break;
200
- const image = await inlineImage(frame.gif || frame.images?.[0]);
201
- if (image) {
202
- content.push(image);
203
- inlined++;
204
- }
205
- }
206
- }
207
- return { content };
208
- }
209
-
210
- const server = new McpServer({ name: "voidless", version: VERSION });
211
-
212
- // Tool annotations (MCP ToolAnnotations). Generation tools create something new in the caller's
213
- // Voidless library and spend credits: none deletes or overwrites (edits append new frames; the
214
- // source is kept), a repeat call makes another result and bills again, and the effects stay
215
- // inside the user's own account. Directories such as Claude's require title +
216
- // readOnlyHint/destructiveHint on every tool.
217
- function writeTool(title) {
218
- return { title, readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
219
- }
220
- function readTool(title) {
221
- return { title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
222
- }
223
- // Tagging changes labels only (no credits), but replace/remove drop existing tags.
224
- function tagTool(title) {
225
- return { title, readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
226
- }
227
-
228
- const assetId = z.number().int().describe("Asset ID (from list_assets / get_asset or a previous result)");
229
- const referenceFile = z
230
- .string()
231
- .default("")
232
- .describe(
233
- "Work from one image of an existing frame instead of the asset's base: pass an image key exactly as " +
234
- "get_asset lists it. Empty = the asset's current base image."
235
- );
236
- const frameId = z
237
- .number()
238
- .int()
239
- .optional()
240
- .describe("Optional: the frame the new frames hang under in the editor (usually the frame referenceFile came from).");
241
-
242
- // ---------------------------------------------------------------------------
243
- // Reading the library
244
- // ---------------------------------------------------------------------------
245
-
246
- server.tool(
247
- "list_assets",
248
- "List the user's Voidless assets, newest first: id, type, prompt, tags and how many frames/animations " +
249
- "each has. Use this to find an asset to animate, rotate or edit. Free.",
250
- {
251
- type: z.enum(["image", "sfx", "music", "video", "model"]).optional().describe("Only this asset type"),
252
- tag: z.string().optional().describe("Only assets whose tags contain this text"),
253
- page: z.number().int().min(1).default(1),
254
- pageSize: z.number().int().min(1).max(100).default(25),
255
- },
256
- readTool("List assets"),
257
- tool(async ({ type, tag, page, pageSize }) => {
258
- const q = [];
259
- if (type) q.push(`filters[$and][0][type]=${type}`);
260
- if (tag) q.push(`filters[tags][$contains]=${encodeURIComponent(tag)}`);
261
- q.push("sort=createdAt:desc", `pagination[page]=${page}`, `pagination[pageSize]=${pageSize}`);
262
- const data = await apiGet(`/assets?${q.join("&")}`);
263
- const rows = (data.data || []).map((item) => {
264
- const a = item.attributes || item;
265
- const frames = (a.meta_data?.frames || []).filter((f) => !f.archived);
266
- const animations = frames.filter((f) => f.type === "animation").length;
267
- const kind = a.meta_data?.image_type ? `${a.type}/${a.meta_data.image_type}` : a.type;
268
- const extra = frames.length ? ` · ${frames.length} frames (${animations} animations)` : "";
269
- const tags = a.tags ? ` · tags: ${a.tags}` : "";
270
- return `- ${item.id} · ${kind} · "${String(a.prompt || "").slice(0, 80)}"${tags}${extra}`;
271
- });
272
- const p = data.meta?.pagination;
273
- const footer = p ? `\nPage ${p.page} of ${p.pageCount} (${p.total} assets)` : "";
274
- return { content: [{ type: "text", text: (rows.join("\n") || "No assets found.") + footer }] };
275
- })
276
- );
277
-
278
- server.tool(
279
- "get_asset",
280
- "Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
281
- "names, facings and image keys. Frame ids are what frameIndex/items/frameId take elsewhere; image keys " +
282
- "are what referenceFile and end_frame take. Free.",
283
- { id: assetId, preview: z.boolean().default(true).describe("Include the base image inline") },
284
- readTool("Get asset"),
285
- tool(async ({ id, preview }) => {
286
- const a = await apiGet(`/assets/${id}`);
287
- const meta = a.meta_data || {};
288
- const frames = (meta.frames || []).filter((f) => !f.archived);
289
- const lines = [
290
- `Asset ${a.id} · ${a.type}${meta.image_type ? "/" + meta.image_type : ""} · ${APP_URL}/asset/${a.id}`,
291
- a.prompt ? `Prompt: ${a.prompt}` : null,
292
- `Tags: ${a.tags || "(none)"}`,
293
- meta.size ? `Size: ${meta.size}px · ${meta.dimension || ""} · style ${meta.style || "-"} · facing ${meta.facing || "-"}` : null,
294
- a.file ? `Base image: ${a.file}` : null,
295
- meta.rigged_file ? `Rigged: yes (${(meta.animations || []).length} animations)` : null,
296
- Array.isArray(meta.video_extensions) && meta.video_extensions.length
297
- ? `Video extensions: ${meta.video_extensions.map((v) => v.file).join(", ")}`
298
- : null,
299
- frames.length ? `Frames (${frames.length}):\n${frames.map(frameLine).join("\n")}` : "No frames yet.",
300
- ].filter(Boolean);
301
- const content = [{ type: "text", text: lines.join("\n") }];
302
- if (preview) {
303
- const image = await inlineImage(a.file);
304
- if (image) content.push(image);
305
- }
306
- return { content };
307
- })
308
- );
309
-
310
- server.tool(
311
- "get_animation_presets",
312
- "List the animation-set presets (platformer-character, topdown-walk/run/dash/attack/idle) with the " +
313
- "actions each one makes, and the quality tiers with their credit cost per pass. Free.",
314
- {},
315
- readTool("Get animation presets"),
316
- tool(async () => {
317
- const data = await apiGet("/generate/multi-animation/presets");
318
- const presets = (data.presets || []).map((p) => `- ${p.key} (${p.label}): ${p.actions.join(", ")}`);
319
- const qualities = (data.qualities || []).map((q) => `- ${q.key}: ${q.resolution}, ${q.credits} credits per pass`);
320
- return {
321
- content: [
322
- {
323
- type: "text",
324
- text: `Presets:\n${presets.join("\n")}\n\nQuality (picked automatically from the sprite size):\n${qualities.join("\n")}\n\nUp to ${data.maxImages} images per run.`,
325
- },
326
- ],
327
- };
328
- })
329
- );
330
-
331
- // ---------------------------------------------------------------------------
332
- // Tagging
333
- // ---------------------------------------------------------------------------
334
-
335
- const tagList = z
336
- .array(z.string().min(1).max(40))
337
- .min(1)
338
- .describe("Tags, e.g. ['player', 'level-1']. Case-insensitive duplicates are merged");
339
- const tagMode = z
340
- .enum(["add", "remove", "replace"])
341
- .default("add")
342
- .describe("add = keep existing tags and add these, remove = drop these, replace = set exactly these");
343
-
344
- function applyTags(current, tags, mode) {
345
- const existing = String(current || "").split(",").map((t) => t.trim()).filter(Boolean);
346
- const lower = new Set(tags.map((t) => t.toLowerCase()));
347
- const merged = mode === "replace" ? tags : mode === "remove" ? existing.filter((t) => !lower.has(t.toLowerCase())) : [...existing, ...tags];
348
- // Same rule as the server's normalizeTags: case-insensitive duplicates keep the first spelling.
349
- const seen = new Map();
350
- for (const t of merged) if (!seen.has(t.toLowerCase())) seen.set(t.toLowerCase(), t);
351
- return [...seen.values()];
352
- }
353
-
354
- server.tool(
355
- "tag_asset",
356
- "Add, remove or replace the tags on an asset, so it can be found again with list_assets(tag). Free.",
357
- { id: assetId, tags: tagList, mode: tagMode },
358
- tagTool("Tag asset"),
359
- tool(async ({ id, tags, mode }) => {
360
- const asset = await apiGet(`/assets/${id}`);
361
- const next = applyTags(asset.tags, tags, mode).join(",");
362
- const data = await apiPut(`/assets/${id}`, { data: { tags: next } });
363
- const saved = (data.data?.attributes || data.data || data).tags ?? next;
364
- return { content: [{ type: "text", text: `Asset ${id} tags: ${saved || "(none)"}` }] };
365
- })
366
- );
367
-
368
- server.tool(
369
- "tag_frame",
370
- "Add, remove or replace the tags on one frame of an image asset (an animation, edit or part), e.g. " +
371
- "to mark which clip is 'idle' or 'final'. 8-way rotation frames cannot be tagged: their tags are the " +
372
- "facings the animation presets rely on. Free.",
373
- {
374
- id: assetId,
375
- frameIndex: z.number().int().describe("Frame id from get_asset"),
376
- tags: tagList,
377
- mode: tagMode,
378
- },
379
- tagTool("Tag frame"),
380
- tool(async ({ id, frameIndex, tags, mode }) => {
381
- const asset = await apiGet(`/assets/${id}`);
382
- const frame = (asset.meta_data?.frames || []).find((f) => f.id === frameIndex && !f.archived);
383
- if (!frame) throw new Error(`Asset ${id} has no frame ${frameIndex}. Use get_asset ${id} for its frame ids.`);
384
- if (isRotation(frame)) {
385
- throw new Error(`Frame ${frameIndex} is an 8-way rotation: its tags are its facings, which animation presets read. Tag the asset instead.`);
386
- }
387
- if (!frame.images?.length) throw new Error(`Frame ${frameIndex} has no images to locate it by.`);
388
- const next = applyTags(frame.tags, tags, mode).join(",");
389
- // The server finds the frame by one of its image files, which stays stable across reindexing.
390
- const data = await apiPut(`/assets/${id}/frame-tags`, { referenceFile: frame.images[0], tags: next });
391
- return { content: [{ type: "text", text: `Frame ${frameIndex} of asset ${id} tags: ${data.tags || "(none)"}` }] };
392
- })
393
- );
394
-
395
- // ---------------------------------------------------------------------------
396
- // Images
397
- // ---------------------------------------------------------------------------
398
-
399
- server.tool(
400
- "generate_image",
401
- "Generate a game sprite/asset image. Infer every field from the request: art STYLE, colour PALETTE, " +
402
- "asset TYPE, output SIZE and PERSPECTIVE; for characters also facing/pose/bodyType. Costs by model: " +
403
- "flux2-klein 1, flux-juggernaut 1, seedream-5-flash 2, gpt-image-2.5 5, retro-diffusion 10, " +
404
- "nano-banana-2 15 credits (a reference image makes it 5 and uses flux2-klein).",
405
- {
406
- prompt: z.string().max(300).describe("The subject only (max 300 chars). Put style/colour in the style/palette fields."),
407
- model: z
408
- .enum(["flux2-klein", "gpt-image-2.5", "seedream-5-flash", "nano-banana-2", "retro-diffusion", "flux-juggernaut"])
409
- .default("flux2-klein")
410
- .describe(
411
- "flux2-klein: fast, cheap default. retro-diffusion: true 8/16-bit pixel art. gpt-image-2.5 / nano-banana-2: " +
412
- "best prompt following and text. seedream-5-flash / flux-juggernaut: painterly and detailed."
413
- ),
414
- type: z
415
- .enum(["object", "character", "character portrait", "artwork", "tileset", "ui"])
416
- .describe(
417
- "'character' = full-body sprite (uses facing/pose/bodyType); 'character portrait' = headshot; 'object' = props " +
418
- "and items; 'artwork' = scenes/backgrounds; 'tileset' = map tiles; 'ui' = interface pieces (see uiMode)."
419
- ),
420
- dimension: z.enum(["2d", "top down", "isometric"]).default("2d").describe("'2d' = side view, 'top down' = overhead, 'isometric' = 2.5D"),
421
- size: z
422
- .union([z.literal(32), z.literal(64), z.literal(128), z.literal(512), z.literal(1024)])
423
- .default(512)
424
- .describe("Output px. 32/64/128 give a pixel-art look; 512 for every other style; 1024 for high-res."),
425
- colours: z.number().int().min(4).max(12).default(8).describe("Colour count for pixel-art reduction (4–12)"),
426
- style: z
427
- .string()
428
- .default("")
429
- .describe(
430
- "Style preset: pixel-art, cartoon, cel-shading, flat, hand-drawn, paper-cutout, comic-book, anime, watercolor, " +
431
- "low-poly, 3d-render, claymation, photorealistic, voxel. Empty = pixel art below 512, cartoon otherwise."
432
- ),
433
- palette: z
434
- .enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia"])
435
- .default("none")
436
- .describe("Colour-scheme preset, independent of style. 'none' keeps the style's own colours."),
437
- facing: z.enum(["forward", "right"]).default("forward").describe("Characters: front view or side profile. Side profiles rotate best."),
438
- pose: z.enum(["idle", "tpose"]).default("idle").describe("Characters: relaxed stance, or T-pose for rigging"),
439
- bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Characters: body plan"),
440
- uiMode: z
441
- .enum(["graphic", "title", "text"])
442
- .default("graphic")
443
- .describe("type 'ui' only: 'graphic' = button/panel/HUD piece, 'title' = game-title wordmark spelling the prompt, 'text' = plain label spelling the prompt"),
444
- seed: z.number().int().min(0).default(0).describe("RNG seed (0 = random)"),
445
- referenceImage: z.string().default("").describe("Optional raw base64 image (no data: prefix) to base the result on"),
446
- styleImage: z.string().default("").describe("Optional raw base64 image whose art style to copy"),
447
- },
448
- writeTool("Generate image"),
449
- tool(async ({ facing, pose, bodyType, palette, uiMode, ...rest }) => {
450
- const body = { ...rest, palette: [], options: { facing, pose, bodyType, palette, mode: uiMode } };
451
- return assetResult(await apiPost("/generate/image", body), "Image");
452
- })
453
- );
454
-
455
- server.tool(
456
- "edit_image",
457
- "Edit an existing image asset with an instruction (change colours, add a hat, fix a detail…). The edit is " +
458
- "added as a new frame; the original stays. 5 credits.",
459
- {
460
- id: assetId,
461
- prompt: z.string().describe("What to change"),
462
- model: z
463
- .enum(["p-image", "klein", "nano", "gpt-image-2.5"])
464
- .default("p-image")
465
- .describe("p-image: the app's default editor. nano: most faithful to the original. gpt-image-2.5: best at following complex changes. klein: fastest."),
466
- referenceFile,
467
- frameId,
468
- images: z.array(z.string()).max(4).default([]).describe("Optional extra reference images: image keys from get_asset or https URLs"),
469
- remove_bg: z.boolean().default(false).describe("Remove the background from the result"),
470
- },
471
- writeTool("Edit image"),
472
- tool(async (args) => framesResult(await apiPost("/generate/image-edit", args), args.id, "Edit"))
473
- );
474
-
475
- server.tool(
476
- "rotate_sprite",
477
- "Turn a sprite into an 8-way set (front, back, sides and diagonals) in one pass. The result is a " +
478
- "'rotations - 8-way' frame whose facings line up with its images; pass those to generate_animation_set " +
479
- "to animate each direction. Works best from a side-profile sprite. 10 credits.",
480
- { id: assetId, referenceFile, frameId },
481
- writeTool("Rotate sprite (8-way)"),
482
- tool(async (args) => framesResult(await apiPost("/generate/image-rotations", args), args.id, "8-way rotation"))
483
- );
484
-
485
- server.tool(
486
- "extract_parts",
487
- "Split a character sprite into separate body parts (head, torso, arms, legs…) as 'layer' frames, for " +
488
- "rigging or cut-out animation. 5 credits. Call get_asset afterwards for the parts' frame ids.",
489
- {
490
- id: assetId,
491
- prompt: z.string().default("").describe("Optional: which parts to cut out. Empty = the standard rig parts"),
492
- referenceFile,
493
- frameId,
494
- },
495
- writeTool("Extract sprite parts"),
496
- tool(async (args) => {
497
- const data = await apiPost("/generate/image-parts", args);
498
- // The ids returned here are off by (parts - 1): the server numbers from the last appended
499
- // frame. Point at get_asset instead of printing ids that would misdirect a follow-up call.
500
- const names = (data.frames || []).map((f) => f.name).filter(Boolean);
501
- return {
502
- content: [
503
- {
504
- type: "text",
505
- text: `Extracted ${names.length} parts from asset ${args.id}: ${names.join(", ")}.\n${money(data)}\nUse get_asset ${args.id} for their frame ids and image keys.`,
506
- },
507
- ],
508
- };
509
- })
510
- );
511
-
512
- server.tool(
513
- "upload_image",
514
- "Add an image the user already has to their library, so it can be animated, rotated or edited. It must " +
515
- "be square at 32, 64, 128, 512 or 1024 px. Free.",
516
- {
517
- image: z.string().describe("Base64 image (a data: prefix is stripped automatically)"),
518
- type: z.enum(["object", "character", "character portrait", "artwork"]).default("character"),
519
- remove_bg: z.boolean().default(false).describe("Remove the background on upload"),
520
- },
521
- writeTool("Upload image"),
522
- tool(async ({ image, ...rest }) => {
523
- const raw = String(image).replace(/^data:[^,]*,/, "");
524
- return assetResult(await apiPost("/upload/image", { image: raw, ...rest }), "Upload");
525
- })
526
- );
527
-
528
- server.tool(
529
- "pixelate_image",
530
- "Convert any image to pixel art at a target size and colour count. Returns the image; nothing is " +
531
- "saved to the library. 1 credit.",
532
- {
533
- image: z.string().describe("Base64 source image (a data: prefix is stripped automatically)"),
534
- size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(512)]).default(64),
535
- colours: z.number().int().min(4).max(12).default(8),
536
- },
537
- writeTool("Pixelate image"),
538
- tool(async ({ image, ...rest }) => {
539
- const data = await apiPost("/pixelate/image", { image: String(image).replace(/^data:[^,]*,/, ""), ...rest });
540
- return {
541
- content: [
542
- { type: "text", text: money(data) || "Pixelated." },
543
- { type: "image", data: data.image, mimeType: "image/png" },
544
- ],
545
- };
546
- })
547
- );
548
-
549
- // ---------------------------------------------------------------------------
550
- // Animation
551
- // ---------------------------------------------------------------------------
552
-
553
- server.tool(
554
- "animate_sprite",
555
- "Animate one sprite into a single 16-frame clip from a description of the motion. For several clips " +
556
- "(walk + run + attack, or every direction) use generate_animation_set instead: it is one job. 15 credits.",
557
- {
558
- id: assetId,
559
- prompt: z.string().describe("The motion, e.g. 'walk cycle', 'swings sword overhead'"),
560
- referenceFile,
561
- end_frame: z.string().default("").describe("Optional image key for the clip to end on (e.g. to loop back to a pose). Empty = free ending"),
562
- frameId,
563
- },
564
- writeTool("Animate sprite"),
565
- tool(async (args) => framesResult(await apiPost("/generate/image-animation", { ...args, mode: "f" }), args.id, "Animation"))
566
- );
567
-
568
- const pick = z.object({
569
- assetId: z.number().int().describe("Asset to animate"),
570
- frameIndex: z.number().int().nullable().optional().describe("Frame id from get_asset (e.g. an 8-way rotation frame). Omit for the asset's base image"),
571
- imageIndex: z.number().int().optional().describe("Which image of that frame (for a rotation frame: the facing's position in its facings list)"),
572
- base: z.boolean().optional().describe("true = the asset's original base image, which counts as the 'down' (front) facing"),
573
- });
574
-
575
- server.tool(
576
- "generate_animation_set",
577
- "Generate several animations in one job. Two ways to use it:\n" +
578
- "1. A PRESET on one sprite: platformer-character (idle, run, jump, attack-1, death, hurt) or a top-down " +
579
- "set (topdown-walk/run/dash/attack: 8 directions; topdown-idle: 4). Each item gets the preset's actions.\n" +
580
- "2. A PRESET across an 8-way rotation set: one item per facing — {assetId, base:true} for 'down' plus " +
581
- "{assetId, frameIndex:<rotation frame id>, imageIndex:i} for each other facing — and every facing gets its " +
582
- "own directional clip (walk-left from the left-facing image, …). For topdown-idle pick only the 4 " +
583
- "cardinals (down, right, up, left).\n" +
584
- "Without a preset, describe the actions in the prompt ('idle, run, jump') for up to 6 custom clips. " +
585
- "Cost: 100 credits per pass (200 for 1024px sprites, which run in 4K); a pass holds up to 6 clips, so an " +
586
- "8-way set is 2 passes. Clips are added to their source asset as frames named after the action.",
587
- {
588
- items: z.array(pick).min(1).max(12).describe("Images to animate, in order"),
589
- preset: z
590
- .enum(["platformer-character", "topdown-walk", "topdown-run", "topdown-dash", "topdown-attack", "topdown-idle"])
591
- .optional()
592
- .describe("Named set of actions (see get_animation_presets)"),
593
- prompt: z.string().default("").describe("Without a preset: the actions to make. With one: optional extra direction for every clip"),
594
- holdFacing: z.boolean().default(false).describe("End every clip on the frame it started from, so directional loops do not drift"),
595
- },
596
- writeTool("Generate animation set"),
597
- tool(async (args) => resultsContent(await apiPost("/generate/multi-animation", args), "Animation set generated."))
598
- );
599
-
600
- server.tool(
601
- "edit_animation_set",
602
- "Re-generate existing animations with a change (e.g. 'make the attack faster', 'add a cape that " +
603
- "flows'), as new clips next to the originals. This re-animates; to change how the character LOOKS " +
604
- "while keeping the exact motion, use preview_keep_motion_edit. 100 credits (200 at 1024px) per run.",
605
- {
606
- items: z
607
- .array(z.object({ assetId: z.number().int(), frameIndex: z.number().int().describe("Animation frame id from get_asset") }))
608
- .min(1)
609
- .max(12),
610
- prompt: z.string().describe("The change to make"),
611
- },
612
- writeTool("Edit animation set"),
613
- tool(async (args) => resultsContent(await apiPost("/generate/multi-animation/edit", args), "Animations edited."))
614
- );
615
-
616
- // Keep motion: the preview has to be approved before it is applied, and the apply call must send
617
- // the preview's files back unchanged. They are held here per asset so the model only has to say
618
- // "apply" with the same prompt; explicit preview/stages still win if given.
619
- const pendingKeepMotion = new Map();
620
-
621
- server.tool(
622
- "preview_keep_motion_edit",
623
- "Step 1 of a 'keep motion' edit: change how a character looks (outfit, colours, a held item…) across " +
624
- "existing animations WITHOUT re-animating them. This draws the change on one frame for approval and saves " +
625
- "nothing. Show it to the user; if they approve, call apply_keep_motion_edit with the same id, prompt and " +
626
- "animations. 5 credits (10 if the prompt both changes the character and adds an item).",
627
- {
628
- id: assetId,
629
- prompt: z.string().describe("The look change, e.g. 'red armour and a blue cape'"),
630
- animations: z.array(z.number().int()).default([]).describe("Animation frame ids to edit (from get_asset). The preview uses the first one"),
631
- },
632
- writeTool("Preview keep-motion edit"),
633
- tool(async ({ id, prompt, animations }) => {
634
- const data = await apiPost("/generate/frame-edit/preview", { id, prompt, items: animations });
635
- pendingKeepMotion.set(id, { prompt, preview: data.preview, stages: data.stages });
636
- const content = [
637
- {
638
- type: "text",
639
- text: [
640
- `Preview of "${prompt}" on asset ${id}. Nothing is saved yet.`,
641
- data.stages?.added ? `Split into: change "${data.stages.whole}" + add "${data.stages.added}".` : null,
642
- money(data),
643
- `If the user approves, call apply_keep_motion_edit with id ${id}, the same prompt and the animations to change.`,
644
- ]
645
- .filter(Boolean)
646
- .join("\n"),
647
- },
648
- ];
649
- const image = await inlineImage(data.preview?.images?.[0]);
650
- if (image) content.push(image);
651
- return { content };
652
- })
653
- );
654
-
655
- server.tool(
656
- "apply_keep_motion_edit",
657
- "Step 2 of a 'keep motion' edit: apply an approved preview to every frame of the chosen animations, " +
658
- "keeping their motion exactly. New animations named '<original> (<change>)' are added next to the " +
659
- "originals. Costs 15 credits per 16 frames per pass (1 pass, or 2 when the change also adds an item); " +
660
- "at most 256 frames.",
661
- {
662
- id: assetId,
663
- prompt: z.string().describe("The same prompt as the approved preview"),
664
- animations: z.array(z.number().int()).min(1).describe("Animation frame ids to apply it to"),
665
- frameId,
666
- },
667
- writeTool("Apply keep-motion edit"),
668
- tool(async ({ id, prompt, animations, frameId }) => {
669
- const pending = pendingKeepMotion.get(id);
670
- if (!pending || pending.prompt !== prompt) {
671
- throw new Error(`No approved preview for asset ${id} with that prompt. Call preview_keep_motion_edit first.`);
672
- }
673
- const data = await apiPost("/generate/frame-edit", {
674
- id,
675
- prompt,
676
- items: animations,
677
- frameId,
678
- preview: pending.preview,
679
- stages: pending.stages,
680
- });
681
- pendingKeepMotion.delete(id);
682
- return framesResult(data, id, "Keep-motion edit");
683
- })
684
- );
685
-
686
- // ---------------------------------------------------------------------------
687
- // Audio
688
- // ---------------------------------------------------------------------------
689
-
690
- server.tool(
691
- "generate_sfx",
692
- "Generate a short sound effect (coin pickup, sword hit, footstep…). 5 credits.",
693
- { prompt: z.string().describe("The sound to make") },
694
- writeTool("Generate sound effect"),
695
- tool(async ({ prompt }) => assetResult(await apiPost("/generate/sfx", { prompt }), "Sound effect"))
696
- );
697
-
698
- server.tool(
699
- "generate_music",
700
- "Generate a music track. 10 credits.",
701
- {
702
- prompt: z.string().min(10).max(300).describe("Genre, mood and instruments, e.g. 'upbeat chiptune for a title screen' (10–300 chars)"),
703
- duration: z.number().int().min(30).max(120).default(60).describe("Seconds (30–120)"),
704
- bpm: z.number().int().min(80).max(200).default(100),
705
- keyScale: z.string().default("C major").describe("e.g. 'A minor', 'D major'"),
706
- },
707
- writeTool("Generate music"),
708
- tool(async (args) => assetResult(await apiPost("/generate/music", args), "Music"))
709
- );
710
-
711
- // ---------------------------------------------------------------------------
712
- // Video
713
- // ---------------------------------------------------------------------------
714
-
715
- server.tool(
716
- "generate_video",
717
- "Generate a short video clip (trailer shot, cutscene, animated background) from a prompt and 1–7 " +
718
- "reference images. 15 credits per second.",
719
- {
720
- prompt: z.string().describe("What happens in the clip"),
721
- images: z.array(z.string()).min(1).max(7).describe("Reference images: image keys from get_asset, https URLs, or data: URIs"),
722
- duration: z.number().int().min(4).max(15).default(4).describe("Seconds (4–15)"),
723
- width: z.number().int().default(1280),
724
- height: z.number().int().default(720),
725
- },
726
- writeTool("Generate video"),
727
- tool(async (args) => assetResult(await apiPost("/generate/video", args), "Video"))
728
- );
729
-
730
- server.tool(
731
- "extend_video",
732
- "Continue an existing video asset from its last frame. 15 credits per second.",
733
- {
734
- assetId: z.number().int().describe("The video asset"),
735
- prompt: z.string().describe("What happens next"),
736
- video: z.string().default("").describe("Which clip to continue (a file key from get_asset). Empty = the latest extension, else the original"),
737
- duration: z.number().int().min(4).max(15).default(4),
738
- width: z.number().int().default(1280),
739
- height: z.number().int().default(720),
740
- },
741
- writeTool("Extend video"),
742
- tool(async ({ video, ...args }) => {
743
- let source = video;
744
- if (!source) {
745
- const asset = await apiGet(`/assets/${args.assetId}`);
746
- const extensions = asset.meta_data?.video_extensions || [];
747
- source = extensions.length ? extensions[extensions.length - 1].file : asset.file;
748
- }
749
- const data = await apiPost("/generate/video-extend", { ...args, video: source, images: [] });
750
- const clips = (data.videos || []).map((v) => `- ${cdnUrl(v.file)}`).join("\n");
751
- return { content: [{ type: "text", text: `Extended video ${args.assetId}:\n${clips}\n${money(data)}` }] };
752
- })
753
- );
754
-
755
- // ---------------------------------------------------------------------------
756
- // 3D
757
- // ---------------------------------------------------------------------------
758
-
759
- server.tool(
760
- "generate_3d_model",
761
- "Generate a 3D model (GLB) from a prompt. 15 credits (lq) or 25 (hq).",
762
- {
763
- prompt: z.string().describe("The model to make"),
764
- type: z.enum(["object", "character"]).default("object").describe("'character' makes a T-posed figure ready for rigging"),
765
- style: z.string().default("cartoon style").describe("e.g. 'cartoon style', 'low-poly', 'realistic'"),
766
- mode: z.enum(["lq", "hq"]).default("hq").describe("lq = fast draft, hq = detailed"),
767
- bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Characters only"),
768
- seed: z.number().int().min(0).default(0),
769
- },
770
- writeTool("Generate 3D model"),
771
- tool(async ({ bodyType, ...rest }) => {
772
- const options = rest.type === "character" ? { pose: "tpose", bodyType } : {};
773
- return assetResult(await apiPost("/generate/3d-model", { ...rest, options }), "3D model");
774
- })
775
- );
776
-
777
- server.tool(
778
- "rig_3d_model",
779
- "Add a skeleton to a humanoid 3D model so it can be animated (non-humanoids are refused before any " +
780
- "charge). Run before animate_3d_model. 140 credits.",
781
- {
782
- id: z.number().int().describe("3D model asset ID"),
783
- height_meters: z.number().min(0.2).max(10).default(1.7).describe("Character height in metres"),
784
- },
785
- writeTool("Rig 3D model"),
786
- tool(async (args) => {
787
- const data = await apiPost("/generate/model-rig", args);
788
- return { content: [{ type: "text", text: `Rigged model ${args.id}: ${cdnUrl(data.rigged_glb || data.rigged_file)}\n${money(data)}` }] };
789
- })
790
- );
791
-
792
- server.tool(
793
- "animate_3d_model",
794
- "Animate a rigged 3D model from a description of the motion. 30 credits.",
795
- {
796
- id: z.number().int().describe("Rigged 3D model asset ID"),
797
- prompt: z.string().describe("The motion, e.g. 'walk cycle', 'idle breathing'"),
798
- duration: z.number().min(1).max(10).default(3).describe("Seconds"),
799
- },
800
- writeTool("Animate 3D model"),
801
- tool(async (args) => {
802
- const data = await apiPost("/generate/model-animation", args);
803
- const anim = data.animation || {};
804
- return {
805
- content: [{ type: "text", text: `Animation "${anim.name || args.prompt}" on model ${args.id}: ${cdnUrl(anim.file)}\n${money(data)}` }],
806
- };
807
- })
808
- );
809
-
810
- // ---------------------------------------------------------------------------
811
- // Start
812
- // ---------------------------------------------------------------------------
813
- const transport = new StdioServerTransport();
814
- await server.connect(transport);
22
+ const server = createVoidlessServer(API_KEY);
23
+ await server.connect(new StdioServerTransport());