voidless-mcp 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +44 -17
  2. package/index.js +629 -354
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -33,28 +33,55 @@ claude mcp add voidless -e VOIDLESS_API_KEY=void_... -- npx -y voidless-mcp
33
33
 
34
34
  ## Tools
35
35
 
36
+ **Your library** (free, read-only)
37
+
38
+ | Tool | What it does |
39
+ | --- | --- |
40
+ | `list_assets` | Your assets, newest first, filterable by type or tag |
41
+ | `get_asset` | One asset with every frame: animations, 8-way rotations, edits and parts, with the ids and image keys the other tools take |
42
+ | `get_animation_presets` | The animation-set presets and what each pass costs |
43
+
44
+ **Tagging** (free)
45
+
46
+ | Tool | What it does |
47
+ | --- | --- |
48
+ | `tag_asset` | Add, remove or replace an asset's tags |
49
+ | `tag_frame` | Add, remove or replace the tags on one animation, edit or part |
50
+
51
+ **Images**
52
+
36
53
  | Tool | What it does |
37
54
  | --- | --- |
38
- | `generate_image` | Sprite, character, portrait, prop, tileset or artwork in a chosen style, palette, size and perspective |
39
- | `generate_image_edit` | Edit an image with a text instruction |
40
- | `generate_image_rotate` | Rotated views of a sprite (top-down / isometric angle sheets) |
41
- | `generate_image_parts` | Split a sprite into parts (head, body, limbs) |
42
- | `generate_image_animation` | Animate a sprite (walk, idle, ...) |
43
- | `generate_multi_animation` | Several animations for a character in one pass |
44
- | `generate_multi_animation_edit` | Edit an animation set with a text instruction |
45
- | `generate_image_animation_edit` | Edit one frame of an animation |
46
- | `generate_pixelate_image` | Convert any image to pixel art |
47
- | `upload_image` | Upload an image to your library |
48
- | `generate_sfx` | Sound effect from a prompt |
49
- | `generate_music` | Music track from a prompt |
50
- | `generate_video` / `generate_video_extend` | Short video clip, or extend one |
51
- | `generate_3d_model` / `generate_3d_model_rig` / `generate_3d_model_animation` | 3D model, rig it, animate it |
52
-
53
- Every tool creates new content in your library; none deletes or overwrites existing assets.
55
+ | `generate_image` | Sprite, character, portrait, prop, tileset, UI piece or artwork in a chosen model, style, palette, size and perspective |
56
+ | `edit_image` | Change an image with an instruction; the edit is added as a new frame |
57
+ | `rotate_sprite` | An 8-way set (front, back, sides, diagonals) in one pass |
58
+ | `extract_parts` | Split a character into body parts for rigging |
59
+ | `upload_image` | Add an image you already have to your library |
60
+ | `pixelate_image` | Convert any image to pixel art |
61
+
62
+ **Animation**
63
+
64
+ | Tool | What it does |
65
+ | --- | --- |
66
+ | `animate_sprite` | One 16-frame clip from a description of the motion |
67
+ | `generate_animation_set` | Several clips in one job: a preset (platformer, top-down walk/run/dash/attack/idle) on one sprite, or one directional clip per facing of an 8-way set |
68
+ | `edit_animation_set` | Re-generate existing animations with a change |
69
+ | `preview_keep_motion_edit` / `apply_keep_motion_edit` | Change how a character looks across its animations without re-animating them: preview on one frame, then apply to every frame |
70
+
71
+ **Sound, video and 3D**
72
+
73
+ | Tool | What it does |
74
+ | --- | --- |
75
+ | `generate_sfx` / `generate_music` | Sound effect, or a music track with length, tempo and key |
76
+ | `generate_video` / `extend_video` | Short clip from a prompt and reference images, or continue one |
77
+ | `generate_3d_model` / `rig_3d_model` / `animate_3d_model` | 3D model, rig it, animate it |
78
+
79
+ Generation tools create new content in your library; none deletes or overwrites existing assets. Each tool's description lists its credit cost.
54
80
 
55
81
  ## Example prompts
56
82
 
57
- - "Make a 64px pixel-art knight, side view, then animate a walk cycle."
83
+ - "Make a 64px pixel-art knight, side view, turn it into an 8-way set and give it a top-down walk in every direction."
84
+ - "Put red armour on my knight in all its animations without changing the motion."
58
85
  - "Generate a top-down grass and dirt tileset in a gameboy palette."
59
86
  - "Give me a coin pickup sound effect and a short chiptune loop for the title screen."
60
87
  - "Create a low-poly treasure chest 3D model, rig it and animate it opening."
package/index.js CHANGED
@@ -2,536 +2,811 @@
2
2
  /**
3
3
  * Voidless MCP Server
4
4
  *
5
- * Exposes the Voidless generation API as MCP tools so any MCP-compatible
6
- * client (Claude Desktop, etc.) can call image gen, audio gen, game agent,
7
- * video gen, 3D model gen, and more.
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.
8
9
  *
9
10
  * Required environment variables:
10
- * VOIDLESS_API_KEY - void_... API key from your Voidless account
11
+ * 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.
11
16
  */
12
17
 
13
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
19
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
20
  import { z } from "zod";
16
21
 
17
- const BASE_URL = "https://api.voidless.dev";
22
+ const VERSION = "1.1.0";
23
+ const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
18
24
  const CDN_URL = "https://cdn.voidless.dev";
19
25
  const APP_URL = "https://voidless.dev";
20
26
  const API_KEY = process.env.VOIDLESS_API_KEY || "";
21
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
+
22
31
  if (!API_KEY) {
23
32
  console.error("[voidless-mcp] VOIDLESS_API_KEY is not set (expected a void_... key)");
24
33
  process.exit(1);
25
34
  }
26
35
 
27
- async function apiPost(path, body) {
28
- const url = `${BASE_URL}/api${path}`;
29
- const res = await fetch(url, {
30
- method: "POST",
31
- headers: {
32
- "Content-Type": "application/json",
33
- "x-api-key": API_KEY,
34
- },
35
- body: JSON.stringify(body),
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),
36
41
  });
37
42
 
38
43
  let data;
39
- const contentType = res.headers.get("content-type") || "";
44
+ const text = await res.text();
40
45
  try {
41
- data = contentType.includes("application/json")
42
- ? await res.json()
43
- : { raw: await res.text() };
46
+ data = JSON.parse(text);
44
47
  } catch {
45
- data = { raw: await res.text() };
48
+ data = { raw: text };
46
49
  }
47
50
 
48
51
  if (!res.ok) {
49
- const msg =
50
- data?.error?.message ||
51
- data?.message ||
52
- data?.raw ||
53
- `HTTP ${res.status}`;
52
+ const msg = data?.error?.message || data?.message || data?.raw || `HTTP ${res.status}`;
54
53
  throw new Error(`Voidless API error (${res.status}): ${msg}`);
55
54
  }
56
-
57
55
  return data;
58
56
  }
59
57
 
60
- // Derive the MIME type from a file path extension.
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
+
61
77
  function mimeFromPath(filePath) {
62
78
  const ext = String(filePath || "").split(".").pop().toLowerCase();
63
79
  const map = {
64
- png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
65
- gif: "image/gif", webp: "image/webp",
66
- mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg",
67
- mp4: "video/mp4", webm: "video/webm",
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",
68
82
  glb: "model/gltf-binary", gltf: "model/gltf+json",
69
83
  };
70
84
  return map[ext] || "application/octet-stream";
71
85
  }
72
86
 
73
- // Fetch a CDN file and return it as base64.
74
- async function fetchAsBase64(url) {
75
- const res = await fetch(url);
76
- if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
77
- const buf = await res.arrayBuffer();
78
- return Buffer.from(buf).toString("base64");
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(/^\/+/, "")}`;
79
93
  }
80
94
 
81
- /**
82
- * Format any API response that may contain asset data.
83
- * - Asset IDs become clickable links: https://voidless.dev/asset/{id}
84
- * - Image file paths are fetched from the CDN and returned as inline images.
85
- * - Audio / video / 3D file paths are returned as CDN URLs in the text summary.
86
- */
87
- async function formatResult(data) {
88
- const content = [];
89
-
90
- // Collect every asset-like object in the response (top-level or nested).
91
- const assets = [];
92
- function collectAssets(obj) {
93
- if (!obj || typeof obj !== "object") return;
94
- if (Array.isArray(obj)) { obj.forEach(collectAssets); return; }
95
- // An asset has at minimum an id and a file path.
96
- if (obj.id && obj.file) assets.push(obj);
97
- else Object.values(obj).forEach(collectAssets);
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;
98
105
  }
99
- collectAssets(data);
106
+ }
100
107
 
101
- if (assets.length === 0) {
102
- // No asset — just return the raw JSON.
103
- content.push({ type: "text", text: JSON.stringify(data, null, 2) });
104
- return { content };
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
+ }
105
142
  }
143
+ return content;
144
+ }
106
145
 
107
- for (const asset of assets) {
108
- const assetLink = `${APP_URL}/asset/${asset.id}`;
109
- const filePath = String(asset.file || "");
110
- const cdnUrl = filePath ? `${CDN_URL}/${filePath}` : null;
111
- const mime = cdnUrl ? mimeFromPath(filePath) : null;
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
+ }
112
162
 
113
- const lines = [
114
- `Asset URL: ${assetLink}`,
115
- cdnUrl && !mime.startsWith("image/") ? `Media URL: ${cdnUrl}` : null,
116
- asset.prompt ? `Prompt: ${asset.prompt}` : null,
117
- asset.type ? `Type: ${asset.type}` : null,
118
- typeof asset.cost === "number" ? `Cost: ${asset.cost} credits` : null,
119
- typeof asset.credits === "number" ? `Credits remaining: ${asset.credits}` : null,
120
- ].filter(Boolean);
121
- content.push({ type: "text", text: lines.join("\n") });
122
-
123
- // Inline image preview.
124
- if (cdnUrl && mime.startsWith("image/")) {
125
- try {
126
- const b64 = await fetchAsBase64(cdnUrl);
127
- content.push({ type: "image", data: b64, mimeType: mime });
128
- } catch {
129
- content.push({ type: "text", text: `Image preview: ${cdnUrl}` });
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++;
130
204
  }
131
205
  }
132
206
  }
133
-
134
207
  return { content };
135
208
  }
136
209
 
137
- const server = new McpServer({
138
- name: "voidless",
139
- version: "1.0.0",
140
- });
210
+ const server = new McpServer({ name: "voidless", version: VERSION });
141
211
 
142
- // Tool annotations (MCP ToolAnnotations). Every tool here creates something new in the
143
- // caller's Voidless library and spends credits: none only reads, none deletes or overwrites
144
- // (edits append new frames/assets; the source asset is kept), a repeat call makes a second
145
- // asset and bills again (not idempotent), and the effects stay inside the user's own
146
- // Voidless account (closed world). Directories such as Claude's require title +
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 +
147
216
  // readOnlyHint/destructiveHint on every tool.
148
217
  function writeTool(title) {
149
- return {
150
- title,
151
- readOnlyHint: false,
152
- destructiveHint: false,
153
- idempotentHint: false,
154
- openWorldHint: false,
155
- };
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 };
156
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
+ );
157
394
 
158
395
  // ---------------------------------------------------------------------------
159
- // Image generation
160
- // POST /api/generate/image
396
+ // Images
161
397
  // ---------------------------------------------------------------------------
398
+
162
399
  server.tool(
163
400
  "generate_image",
164
- "Generate a game sprite/asset image. Infer every field from the user's request: the art STYLE, " +
165
- "COLOUR PALETTE, asset TYPE, output SIZE, and PERSPECTIVE (dimension). For characters also set " +
166
- "facing/pose/bodyType. Returns an asset object with the image URL.",
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).",
167
405
  {
168
- prompt: z.string().max(300).describe("What to generate — the subject only (max 300 chars). Do NOT put style/colour words here; use the style/palette fields."),
406
+ prompt: z.string().max(300).describe("The subject only (max 300 chars). Put style/colour in the style/palette fields."),
169
407
  model: z
170
- .enum([
171
- "flux2-klein",
172
- "grok",
173
- "flux-dev",
174
- "retro-diffusion",
175
- "seedream3",
176
- "z-image",
177
- "p-image",
178
- "qwen",
179
- "juggernaut-flux",
180
- "kontext",
181
- "imagen4",
182
- "sana",
183
- "hidream-i1",
184
- "ideogram3",
185
- "gpt-image-2.5",
186
- "seedream-5-flash",
187
- ])
188
- .describe("Image model. Use 'retro-diffusion' for true 8/16-bit pixel art; 'flux2-klein' or 'imagen4' for high-res/smooth art."),
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
+ ),
189
414
  type: z
190
- .enum(["object", "character", "character portrait", "artwork", "tileset"])
191
- .describe("Asset type. 'character' = full-body sprite (uses facing/pose/bodyType); 'character portrait' = headshot; 'object'/'item' = props; 'artwork' = wallpaper/scene."),
192
- dimension: z
193
- .enum(["2d", "top down", "isometric"])
194
- .describe("Perspective. '2d' = flat side view, 'top down' = overhead RPG map, 'isometric' = 2.5D."),
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"),
195
421
  size: z
196
- .union([z.literal(32), z.literal(64), z.literal(128), z.literal(512)])
197
- .describe("Output px. Use 32/64/128 for pixel-art style (64 default); use 512 for every non-pixel style. Sizes <512 force a pixel-art look."),
198
- colours: z.number().int().min(4).max(12).default(8).describe("Colour count for pixel-art palette reduction (4–12)."),
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)"),
199
426
  style: z
200
427
  .string()
201
428
  .default("")
202
429
  .describe(
203
- "Art style preset — pick one: pixel-art, cartoon, cel-shading, flat, hand-drawn, paper-cutout, " +
204
- "comic-book, anime, watercolor, low-poly, 3d-render, claymation, photorealistic, voxel. " +
205
- "Leave empty to auto-pick from size. Pixel-art style requires size 32/64/128."
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."
206
432
  ),
207
433
  palette: z
208
- .enum([
209
- "none",
210
- "black-and-white",
211
- "retro",
212
- "vibrant",
213
- "pastel",
214
- "neon",
215
- "earthy",
216
- "gameboy",
217
- "sepia",
218
- ])
434
+ .enum(["none", "black-and-white", "retro", "vibrant", "pastel", "neon", "earthy", "gameboy", "sepia"])
219
435
  .default("none")
220
- .describe("Colour-palette preset that recolours the result (independent of style). 'none' keeps the style's own colours. Use 'black-and-white' for monochrome."),
221
- facing: z.enum(["forward", "right"]).default("forward").describe("Character only: 'forward' (front view) or 'right' (side profile)."),
222
- pose: z.enum(["idle", "tpose"]).default("idle").describe("Character only: 'idle' relaxed stance or 'tpose' rig/reference stance."),
223
- bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Character only: body plan."),
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"),
224
444
  seed: z.number().int().min(0).default(0).describe("RNG seed (0 = random)"),
225
- referenceImage: z.string().default("").describe("Base64-encoded reference image (optional)"),
226
- styleImage: z.string().default("").describe("Base64-encoded style image (optional)"),
227
- styleId: z.number().int().optional().describe("Saved style ID (optional)"),
228
- customPalette: z.array(z.string()).default([]).describe("Optional explicit hex colours to quantize to (rarely needed; prefer the 'palette' preset)."),
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"),
229
447
  },
230
448
  writeTool("Generate image"),
231
- async (args) => {
232
- // The image API takes character extras + the colour-palette preset inside an
233
- // `options` bag; everything else stays top-level. `customPalette` maps to the
234
- // legacy hex-array `palette` field.
235
- const { facing, pose, bodyType, palette, customPalette, ...rest } = args;
236
- const body = {
237
- ...rest,
238
- palette: customPalette,
239
- options: { facing, pose, bodyType, palette },
240
- };
241
- return formatResult(await apiPost("/generate/image", body));
242
- }
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
+ })
243
453
  );
244
454
 
245
-
246
- // ---------------------------------------------------------------------------
247
- // Image edit (inpainting / outpainting style edit)
248
- // POST /api/generate/image-edit
249
- // ---------------------------------------------------------------------------
250
455
  server.tool(
251
- "generate_image_edit",
252
- "Edit an existing image with a text prompt using an AI model.",
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.",
253
459
  {
254
- prompt: z.string().describe("Edit instruction"),
255
- image: z.string().describe("Base64-encoded source image"),
256
- model: z.string().default("klein").describe("Edit model"),
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"),
257
470
  },
258
471
  writeTool("Edit image"),
259
- async (args) => formatResult(await apiPost("/generate/image-edit", args))
472
+ tool(async (args) => framesResult(await apiPost("/generate/image-edit", args), args.id, "Edit"))
260
473
  );
261
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
+ );
262
484
 
263
- // ---------------------------------------------------------------------------
264
- // Image edit — rotate (multi-angle sprite sheet)
265
- // POST /api/generate/image-rotations
266
- // ---------------------------------------------------------------------------
267
485
  server.tool(
268
- "generate_image_rotate",
269
- "Generate rotated views of a sprite/asset (used for top-down or isometric angle sheets).",
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.",
270
489
  {
271
- id: z.number().int().describe("Asset ID to rotate"),
272
- mode: z
273
- .enum(["basic", "basic2"])
274
- .describe("basic = fast low-quality, basic2 = high-quality"),
275
- referenceFile: z.string().optional().describe("Frame filename to use as the base (optional)"),
490
+ id: assetId,
491
+ prompt: z.string().default("").describe("Optional: which parts to cut out. Empty = the standard rig parts"),
492
+ referenceFile,
493
+ frameId,
276
494
  },
277
- writeTool("Generate rotated views"),
278
- async (args) => formatResult(await apiPost("/generate/image-rotations", args))
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
+ })
279
510
  );
280
511
 
281
-
282
-
283
- // ---------------------------------------------------------------------------
284
- // Image parts (isolate / extract sprite parts)
285
- // POST /api/generate/image-parts
286
- // ---------------------------------------------------------------------------
287
512
  server.tool(
288
- "generate_image_parts",
289
- "Extract or generate individual parts (head, body, legs, etc.) from a sprite asset.",
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.",
290
516
  {
291
- id: z.number().int().describe("Asset ID"),
292
- referenceFile: z.string().optional().describe("Specific frame to use as source (optional)"),
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"),
293
520
  },
294
- writeTool("Extract sprite parts"),
295
- async (args) => formatResult(await apiPost("/generate/image-parts", args))
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
+ })
296
526
  );
297
527
 
298
- // ---------------------------------------------------------------------------
299
- // Image animation
300
- // POST /api/generate/image-animation
301
- // ---------------------------------------------------------------------------
302
528
  server.tool(
303
- "generate_image_animation",
304
- "Animate a static sprite asset to produce walking / idle frames.",
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.",
305
532
  {
306
- id: z.number().int().describe("Asset ID to animate"),
307
- mode: z.enum(["sd", "f2"]).default("sd").describe("sd = standard, f2 = advanced (higher quality, higher cost)"),
308
- referenceFile: z.string().optional().describe("Frame to animate (optional)"),
309
- end_frame: z.string().default("").describe("Base64 or filename for the last frame (optional)"),
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),
310
536
  },
311
- writeTool("Animate sprite"),
312
- async (args) => formatResult(await apiPost("/generate/image-animation", args))
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
+ })
313
547
  );
314
548
 
315
-
316
549
  // ---------------------------------------------------------------------------
317
- // Multi-animation (one grid of several animations in a single pass)
318
- // POST /api/generate/multi-animation
550
+ // Animation
319
551
  // ---------------------------------------------------------------------------
552
+
320
553
  server.tool(
321
- "generate_multi_animation",
322
- "Generate several animations for a character in one pass — a grid of clips (walk/idle/attack, " +
323
- "and/or facing directions) rather than one clip at a time. Prefer this over repeated " +
324
- "generate_image_animation calls when more than one clip is wanted: it is a single billed job.",
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.",
325
557
  {
326
- items: z.array(z.any()).describe("Grid cells to generate — one entry per animation slot"),
327
- prompt: z.string().describe("What the character should be doing across the set"),
328
- preset: z.string().optional().describe("Named layout preset (see the presets the app offers)"),
329
- quality: z
330
- .enum(["low", "high", "ultra"])
331
- .default("low")
332
- .describe(
333
- "Cost tier, and the widest one here: measured spend is ~$0.15 low, ~$0.45 high, ~$0.90 ultra " +
334
- "per job. Leave at low unless the result genuinely needs the detail."
335
- ),
336
- reference: z.boolean().default(false).describe("Use the source asset as a visual reference"),
337
- holdFacing: z
338
- .boolean()
339
- .default(false)
340
- .describe("End each cell on the frame it started from, so a directional clip cannot drift"),
341
- slotEnds: z.array(z.any()).default([]).describe("One end frame per grid cell; read in loop mode only"),
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,
342
563
  },
343
- writeTool("Generate animation set"),
344
- async (args) => formatResult(await apiPost("/generate/multi-animation", args))
564
+ writeTool("Animate sprite"),
565
+ tool(async (args) => framesResult(await apiPost("/generate/image-animation", { ...args, mode: "f" }), args.id, "Animation"))
345
566
  );
346
567
 
347
- // ---------------------------------------------------------------------------
348
- // Multi-animation edit
349
- // POST /api/generate/multi-animation/edit
350
- // ---------------------------------------------------------------------------
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
+
351
575
  server.tool(
352
- "generate_multi_animation_edit",
353
- "Edit an existing multi-animation set with a text instruction, across its cells.",
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.",
354
587
  {
355
- items: z.array(z.any()).describe("The cells to edit"),
356
- prompt: z.string().describe("Edit instruction applied to the set"),
357
- quality: z.enum(["low", "high", "ultra"]).default("low").describe("Cost tier — see generate_multi_animation"),
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"),
358
595
  },
359
- writeTool("Edit animation set"),
360
- async (args) => formatResult(await apiPost("/generate/multi-animation/edit", args))
596
+ writeTool("Generate animation set"),
597
+ tool(async (args) => resultsContent(await apiPost("/generate/multi-animation", args), "Animation set generated."))
361
598
  );
362
599
 
363
- // ---------------------------------------------------------------------------
364
- // Image animation edit
365
- // POST /api/generate/image-animation-edit
366
- // ---------------------------------------------------------------------------
367
600
  server.tool(
368
- "generate_image_animation_edit",
369
- "Edit a specific frame of an existing animation.",
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.",
370
605
  {
371
- id: z.number().int().describe("Asset ID"),
372
- frameId: z.string().describe("Frame identifier to edit"),
373
- prompt: z.string().describe("Edit instruction for this frame"),
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"),
374
611
  },
375
- writeTool("Edit animation frame"),
376
- async (args) => formatResult(await apiPost("/generate/image-animation-edit", args))
612
+ writeTool("Edit animation set"),
613
+ tool(async (args) => resultsContent(await apiPost("/generate/multi-animation/edit", args), "Animations edited."))
377
614
  );
378
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();
379
620
 
380
-
381
-
382
- // ---------------------------------------------------------------------------
383
- // Image pixelation
384
- // POST /api/pixelate/image
385
- // ---------------------------------------------------------------------------
386
621
  server.tool(
387
- "generate_pixelate_image",
388
- "Convert any image into pixel art at a target resolution and colour count.",
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).",
389
627
  {
390
- image: z.string().describe("Base64-encoded source image"),
391
- size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(512)]).describe("Target pixel art size"),
392
- colours: z.number().int().min(4).max(12).describe("Number of colours"),
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"),
393
631
  },
394
- writeTool("Pixelate image"),
395
- async (args) => formatResult(await apiPost("/pixelate/image", args))
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
+ })
396
653
  );
397
654
 
398
- // ---------------------------------------------------------------------------
399
- // Image upload
400
- // POST /api/upload/image
401
- // ---------------------------------------------------------------------------
402
655
  server.tool(
403
- "upload_image",
404
- "Upload a raw image to the Voidless asset library.",
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.",
405
661
  {
406
- image: z.string().describe("Base64-encoded image data"),
407
- type: z.string().optional().describe("Asset type label (optional)"),
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,
408
666
  },
409
- writeTool("Upload image"),
410
- async (args) => formatResult(await apiPost("/upload/image", args))
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
+ })
411
684
  );
412
685
 
413
686
  // ---------------------------------------------------------------------------
414
- // SFX generation
415
- // POST /api/generate/sfx
687
+ // Audio
416
688
  // ---------------------------------------------------------------------------
689
+
417
690
  server.tool(
418
691
  "generate_sfx",
419
- "Generate a short sound effect from a text prompt.",
420
- {
421
- prompt: z.string().describe("Description of the sound effect to generate"),
422
- },
692
+ "Generate a short sound effect (coin pickup, sword hit, footstep…). 5 credits.",
693
+ { prompt: z.string().describe("The sound to make") },
423
694
  writeTool("Generate sound effect"),
424
- async (args) => formatResult(await apiPost("/generate/sfx", args))
695
+ tool(async ({ prompt }) => assetResult(await apiPost("/generate/sfx", { prompt }), "Sound effect"))
425
696
  );
426
697
 
427
- // ---------------------------------------------------------------------------
428
- // Music generation
429
- // POST /api/generate/music
430
- // ---------------------------------------------------------------------------
431
698
  server.tool(
432
699
  "generate_music",
433
- "Generate a music track from a text prompt.",
700
+ "Generate a music track. 10 credits.",
434
701
  {
435
- prompt: z.string().describe("Description of the music to generate"),
436
- mode: z
437
- .enum(["small", "medium"])
438
- .default("small")
439
- .describe("small = short track (10 credits), medium = longer track (20 credits)"),
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'"),
440
706
  },
441
707
  writeTool("Generate music"),
442
- async (args) => formatResult(await apiPost("/generate/music", args))
708
+ tool(async (args) => assetResult(await apiPost("/generate/music", args), "Music"))
443
709
  );
444
710
 
445
711
  // ---------------------------------------------------------------------------
446
- // Video generation
447
- // POST /api/generate/video
712
+ // Video
448
713
  // ---------------------------------------------------------------------------
714
+
449
715
  server.tool(
450
716
  "generate_video",
451
- "Generate a short video clip from a text prompt.",
717
+ "Generate a short video clip (trailer shot, cutscene, animated background) from a prompt and 1–7 " +
718
+ "reference images. 15 credits per second.",
452
719
  {
453
- prompt: z.string().describe("Video description"),
454
- model: z
455
- .enum(["seedance", "luma ray2flash", "kling1.6", "vidu q1"])
456
- .default("seedance")
457
- .describe("Video model (seedance=25cr, luma=35cr, kling=50cr, vidu=75cr)"),
458
- palette: z.array(z.string()).default([]).describe("Colour palette (optional)"),
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),
459
725
  },
460
726
  writeTool("Generate video"),
461
- async (args) => formatResult(await apiPost("/generate/video", args))
727
+ tool(async (args) => assetResult(await apiPost("/generate/video", args), "Video"))
462
728
  );
463
729
 
464
- // ---------------------------------------------------------------------------
465
- // Video extend
466
- // POST /api/generate/video-extend
467
- // ---------------------------------------------------------------------------
468
730
  server.tool(
469
- "generate_video_extend",
470
- "Extend an existing video asset with additional AI-generated frames.",
731
+ "extend_video",
732
+ "Continue an existing video asset from its last frame. 15 credits per second.",
471
733
  {
472
- assetId: z.number().int().describe("Asset ID of the video to extend"),
473
- prompt: z.string().describe("Description of what should happen in the extension"),
474
- model: z
475
- .enum(["seedance", "luma ray2flash", "kling1.6", "vidu q1"])
476
- .default("vidu q1")
477
- .describe("Model to use for the extension"),
478
- palette: z.array(z.string()).default([]).describe("Colour palette (optional)"),
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),
479
740
  },
480
741
  writeTool("Extend video"),
481
- async (args) => formatResult(await apiPost("/generate/video-extend", args))
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
+ })
482
753
  );
483
754
 
484
755
  // ---------------------------------------------------------------------------
485
- // 3D model generation
486
- // POST /api/generate/3d-model
756
+ // 3D
487
757
  // ---------------------------------------------------------------------------
758
+
488
759
  server.tool(
489
760
  "generate_3d_model",
490
- "Generate a 3D model from a text prompt.",
761
+ "Generate a 3D model (GLB) from a prompt. 15 credits (lq) or 25 (hq).",
491
762
  {
492
- prompt: z.string().describe("Description of the 3D model"),
493
- mode: z
494
- .enum(["lq", "hq"])
495
- .default("hq")
496
- .describe("lq = low quality / fast (15 credits), hq = high quality (higher cost)"),
497
- palette: z.array(z.string()).default([]).describe("Colour palette (optional)"),
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),
498
769
  },
499
770
  writeTool("Generate 3D model"),
500
- async (args) => formatResult(await apiPost("/generate/3d-model", args))
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
+ })
501
775
  );
502
776
 
503
- // ---------------------------------------------------------------------------
504
- // 3D model rigging
505
- // POST /api/generate/model-rig
506
- // ---------------------------------------------------------------------------
507
777
  server.tool(
508
- "generate_3d_model_rig",
509
- "Rig a generated 3D model — add a skeleton so it can be animated. Run this on a model from " +
510
- "generate_3d_model before generate_3d_model_animation.",
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.",
511
781
  {
512
- id: z.number().int().describe("Asset ID of the 3D model to rig"),
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"),
513
784
  },
514
785
  writeTool("Rig 3D model"),
515
- async (args) => formatResult(await apiPost("/generate/model-rig", args))
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
+ })
516
790
  );
517
791
 
518
- // ---------------------------------------------------------------------------
519
- // 3D model animation
520
- // POST /api/generate/model-animation
521
- // ---------------------------------------------------------------------------
522
792
  server.tool(
523
- "generate_3d_model_animation",
524
- "Animate a rigged 3D model from a text prompt. The model must be rigged first " +
525
- "(generate_3d_model_rig) — an unrigged model has no skeleton to drive.",
793
+ "animate_3d_model",
794
+ "Animate a rigged 3D model from a description of the motion. 30 credits.",
526
795
  {
527
- id: z.number().int().describe("Asset ID of the rigged 3D model"),
528
- prompt: z.string().describe("The motion to generate, e.g. 'walk cycle', 'idle breathing'"),
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"),
529
799
  },
530
800
  writeTool("Animate 3D model"),
531
- async (args) => formatResult(await apiPost("/generate/model-animation", args))
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
+ })
532
808
  );
533
809
 
534
-
535
810
  // ---------------------------------------------------------------------------
536
811
  // Start
537
812
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "voidless-mcp",
3
3
  "mcpName": "dev.voidless/voidless",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "description": "MCP server for Voidless: generate game sprites, animations, sound, music, video and 3D models",
6
6
  "license": "MIT",
7
7
  "author": "Voidless Dev Inc. <contact@voidless.dev>",