voidless-mcp 1.6.11 → 1.6.14

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 +2 -2
  2. package/package.json +1 -1
  3. package/tools.js +79 -26
package/README.md CHANGED
@@ -74,7 +74,7 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
74
74
  | `list_assets` | Your assets, newest first, filterable by type or tag |
75
75
  | `get_asset` | One asset with every frame: animations, 8-way rotations, edits and parts, with the ids and image keys the other tools take |
76
76
  | `check_generation` | The result of a long generation (animation sets, video, 3D) that was still running when its tool returned. Free |
77
- | `get_animation_presets` | The animation-set presets and what each pass costs |
77
+ | `get_animation_presets` | The animation-set presets and what each clip costs |
78
78
 
79
79
  **Tagging** (free)
80
80
 
@@ -100,7 +100,7 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
100
100
  | Tool | What it does |
101
101
  | --- | --- |
102
102
  | `animate_sprite` | One 16-frame clip from a description of the motion |
103
- | `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; the left side is mirrored from the right (`autoFlip` to mirror any right-only clips after the run) |
103
+ | `generate_animation_set` | Several clips in one job, 15 credits each: a preset (platformer, top-down walk/run/dash/attack/idle) on one or more sprites, one directional clip per facing of an 8-way set (the left side mirrored from the right for free; `autoFlip` mirrors any right-only clips after the run), or your own list of actions. Loops end on their first frame |
104
104
  | `get_image_data` | An image, or an animation as one sprite strip, as a small data URI to paste into HTML (for sandboxes such as Claude's HTML artifacts that cannot load outside images), with a CSS snippet to play it |
105
105
  | `flip_left_side` | Mirror the right-side animations into the left side (walk-right -> walk-left), free |
106
106
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "voidless-mcp",
3
3
  "mcpName": "dev.voidless/voidless",
4
- "version": "1.6.11",
4
+ "version": "1.6.14",
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>",
package/tools.js CHANGED
@@ -27,7 +27,7 @@ import { Agent, setGlobalDispatcher } from "undici";
27
27
  // server does.
28
28
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
29
29
 
30
- export const VERSION = "1.6.11";
30
+ export const VERSION = "1.6.14";
31
31
  const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
32
32
  const CDN_URL = "https://cdn.voidless.dev";
33
33
  // Where asset links point. Results link to the asset's page in the app rather than to raw GIFs: the
@@ -73,6 +73,25 @@ class PendingJob extends Error {
73
73
  }
74
74
  }
75
75
 
76
+ // A finished job whose full result the server no longer has (kept as a summary on its log): what it
77
+ // made, and where to look, instead of a result the tool could format.
78
+ class RestoredJob extends Error {
79
+ constructor(result) {
80
+ super(result?.message || "Finished.");
81
+ this.result = result;
82
+ }
83
+ }
84
+
85
+ function restoredText(result) {
86
+ const ids = Array.isArray(result?.assetIds) ? result.assetIds : [];
87
+ return [
88
+ `Done. ${result?.message || "The generation finished."}`,
89
+ ids.length ? `Asset${ids.length === 1 ? "" : "s"}: ${ids.join(", ")}` : null,
90
+ Array.isArray(result?.frameIds) && result.frameIds.length ? `New frame ids: ${result.frameIds.join(", ")}` : null,
91
+ "It has already been made and charged: do not run it again.",
92
+ ].filter(Boolean).join("\n");
93
+ }
94
+
76
95
  export function createVoidlessServer(API_KEY) {
77
96
  async function api(method, path, body, headers = {}) {
78
97
  const res = await fetch(`${BASE_URL}/api${path}`, {
@@ -117,6 +136,10 @@ export function createVoidlessServer(API_KEY) {
117
136
  if (out === null) throw new PendingJob(key, path);
118
137
  // The connection was cut but the job may well be running: check, do not report a failure.
119
138
  if (out.e) throw CUT_OFF.test(String(out.e.message || out.e)) ? new PendingJob(key, path) : out.e;
139
+ // The server already had this key (a resend): it answers with that job's state instead of
140
+ // running it again -- still going (202), or done but only a summary kept.
141
+ if (out.v && out.v.job === key && out.v.status === "running") throw new PendingJob(key, path);
142
+ if (out.v && out.v.restored) throw new RestoredJob(out.v);
120
143
  return out.v;
121
144
  } finally {
122
145
  clearTimeout(timer);
@@ -146,6 +169,7 @@ export function createVoidlessServer(API_KEY) {
146
169
  if (pending.size > 500) pending.delete(pending.keys().next().value);
147
170
  return pendingResult(err.key);
148
171
  }
172
+ if (err instanceof RestoredJob) return { content: [{ type: "text", text: restoredText(err.result) }] };
149
173
  return { isError: true, content: [{ type: "text", text: String(err.message || err) }] };
150
174
  }
151
175
  };
@@ -270,11 +294,25 @@ export function createVoidlessServer(API_KEY) {
270
294
  }
271
295
 
272
296
  // Multi-animation responses: { results: [{ assetId, frames, skipped? }], quality, cost, … }.
297
+ // "2 sprites × 7 clips": one line for the shape of the set.
298
+ function setSummary(data) {
299
+ const made = (data.results || []).filter((r) => !r.skipped && !r.mirrored);
300
+ if (!made.length) return null;
301
+ const perAsset = new Map();
302
+ for (const r of made) perAsset.set(r.assetId, (perAsset.get(r.assetId) || 0) + (r.frames || []).length);
303
+ const counts = [...perAsset.values()];
304
+ const total = counts.reduce((a, n) => a + n, 0);
305
+ const sprites = perAsset.size;
306
+ return counts.every((n) => n === counts[0])
307
+ ? `${sprites} sprite${sprites === 1 ? "" : "s"} × ${counts[0]} clip${counts[0] === 1 ? "" : "s"}`
308
+ : `${total} clips on ${sprites} sprite${sprites === 1 ? "" : "s"}`;
309
+ }
310
+
273
311
  async function resultsContent(data, label) {
274
312
  const content = [
275
313
  {
276
314
  type: "text",
277
- text: [label, data.quality ? `Quality: ${data.quality}` : null, money(data)].filter(Boolean).join("\n"),
315
+ text: [label, setSummary(data), data.quality ? `Quality: ${data.quality}` : null, money(data)].filter(Boolean).join("\n"),
278
316
  },
279
317
  ];
280
318
  let inlined = 0;
@@ -554,18 +592,24 @@ export function createVoidlessServer(API_KEY) {
554
592
  st = await apiGet(`/generate/job/${encodeURIComponent(job)}`);
555
593
  } catch (err) {
556
594
  if (/\(404\)/.test(String(err.message))) {
557
- return { content: [{ type: "text", text: `No job ${job} on record (jobs are kept for 2 hours). ` +
558
- "The result may already be in the library: list_assets or get_asset shows it." }] };
595
+ // Not a failure: the server has no record of the key, which says nothing about the job.
596
+ return { content: [{ type: "text", text: `The server has no record of job ${job}. That does NOT mean it ` +
597
+ "failed: it may well have finished and been charged. Before running anything again, look for the " +
598
+ "result with list_assets (newest first) or get_asset on the asset it was for, and ask the user " +
599
+ "before re-running the tool." }] };
559
600
  }
560
601
  throw err;
561
602
  }
562
603
  if (st.status === "running") {
563
- return { content: [{ type: "text", text: `Still running (${st.seconds}s so far). Check again in about a minute.` }] };
604
+ const where = st.step ? `${st.step}, about ${Math.round((Number(st.fraction) || 0) * 100)}% — ` : "";
605
+ return { content: [{ type: "text", text: `Still running: ${where}${st.seconds}s so far. Check again in about a minute.` }] };
564
606
  }
565
607
  if (st.status === "failed") {
566
608
  const msg = st.result?.error?.message || st.result?.message || `HTTP ${st.httpStatus}`;
567
609
  return { isError: true, content: [{ type: "text", text: `The generation failed: ${msg}` }] };
568
610
  }
611
+ // Finished before a server restart, with only a summary kept: point at the asset.
612
+ if (st.result && st.result.restored) return { content: [{ type: "text", text: restoredText(st.result) }] };
569
613
  const p = pending.get(job);
570
614
  if (!p) {
571
615
  // This connector restarted since the job began: the raw result is all there is.
@@ -612,18 +656,19 @@ export function createVoidlessServer(API_KEY) {
612
656
  server.tool(
613
657
  "get_animation_presets",
614
658
  "List the animation-set presets (platformer-character, topdown-walk/run/dash/attack/idle) with the " +
615
- "actions each one makes, and the quality tiers with their credit cost per pass. Free.",
659
+ "actions each one makes, and the credit cost per clip. Free.",
616
660
  {},
617
661
  readTool("Get animation presets"),
618
662
  tool(async () => {
619
663
  const data = await apiGet("/generate/multi-animation/presets");
620
664
  const presets = (data.presets || []).map((p) => `- ${p.key} (${p.label}): ${p.actions.join(", ")}`);
621
- const qualities = (data.qualities || []).map((q) => `- ${q.key}: ${q.resolution}, ${q.credits} credits per pass`);
665
+ const perClip = Number(data.pricePerClip) > 0 ? Number(data.pricePerClip) : 15;
622
666
  return {
623
667
  content: [
624
668
  {
625
669
  type: "text",
626
- 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.`,
670
+ text: `Presets:\n${presets.join("\n")}\n\n${perClip} credits per clip (mirrored left-side clips are free). ` +
671
+ `Up to ${data.maxImages} images per run.`,
627
672
  },
628
673
  ],
629
674
  };
@@ -758,7 +803,7 @@ export function createVoidlessServer(API_KEY) {
758
803
  aspect: z
759
804
  .enum(["1:1", "16:9"])
760
805
  .default("1:1")
761
- .describe("type 'artwork' only: '16:9' for a backdrop, scene or game background (1344x756 at 1024, 672x378 at 512). Everything that is not artwork is always 1:1"),
806
+ .describe("type 'artwork' at size 512 or 1024 only: '16:9' for a backdrop, scene or game background (1344x756 at 1024, 672x378 at 512; pixel-art styles come out on a 4 px grid, 672x376 at 512). Smaller sizes, Retro Diffusion and everything that is not artwork are always 1:1"),
762
807
  uiMode: z
763
808
  .enum(["graphic", "title", "text"])
764
809
  .default("graphic")
@@ -928,7 +973,8 @@ export function createVoidlessServer(API_KEY) {
928
973
  server.tool(
929
974
  "animate_sprite",
930
975
  "Animate one sprite into a single 16-frame clip from a description of the motion. For several clips " +
931
- "(walk + run + attack, or every direction) use generate_animation_set instead: it is one job. 15 credits.",
976
+ "(walk + run + attack, or every direction) use generate_animation_set instead: one job, " +
977
+ "15 credits per clip. 15 credits.",
932
978
  {
933
979
  id: assetId,
934
980
  prompt: z.string().describe("The motion, e.g. 'walk cycle', 'swings sword overhead'"),
@@ -949,22 +995,24 @@ export function createVoidlessServer(API_KEY) {
949
995
 
950
996
  server.tool(
951
997
  "generate_animation_set",
952
- "Generate several animations in one job. Two ways to use it:\n" +
953
- "1. A PRESET on one sprite: platformer-character (idle, run, jump, attack-1, death, hurt) or a top-down " +
954
- "set (topdown-walk/run/dash/attack: 8 directions; topdown-idle: 4). Each item gets the preset's actions.\n" +
998
+ "Generate several animations in one job, one clip per animation, all run in parallel. Ways to use it:\n" +
999
+ "1. A PRESET on one or more sprites: platformer-character (idle, walk, run, jump, attack-1, death, hurt) or " +
1000
+ "a top-down set (topdown-walk/run/dash/attack: 8 directions; topdown-idle: 4). Every sprite gets every " +
1001
+ "action of the preset.\n" +
955
1002
  "2. A PRESET across an 8-way rotation set: one item per facing, {assetId, frameIndex:<rotation frame id>, " +
956
1003
  "imageIndex:i} where i is the facing's position in the frame's facings list, and every facing gets its own " +
957
1004
  "directional clip (walk-left from the left-facing image, …). If the facings list has no 'down' (the sprite " +
958
1005
  "was drawn facing the camera), add {assetId, base:true} as 'down'. If it already has 'down' (a side-view " +
959
1006
  "sprite), use only the rotation's own images: the base is the side view, not the front. For topdown-idle " +
960
- "pick only the 4 cardinals (down, right, up, left).\n" +
961
- "Without a preset, list the actions in the prompt for up to 6 custom clips, one clip each: " +
962
- "'name: description' separated by semicolons names each clip (e.g. 'attack-2: an overhead slash; block: raise " +
963
- "the sword to guard'); for one image a plain 'idle, run, jump' also works. Every image gets every action. " +
964
- "Cost: 100 credits per pass (200 for 1024px sprites, which run in 4K); a pass holds up to 6 clips. On an " +
965
- "8-way rotation set the left, up-left and down-left clips are mirrored from the right-side ones (the " +
966
- "rotation's left side is a mirror already), so an 8-way set is ONE pass; set flipFacings false to animate " +
967
- "all eight (2 passes). Clips are added to their source asset as frames named after the action.",
1007
+ "pick only the 4 cardinals (down, right, up, left). The left, up-left and down-left clips are mirrored " +
1008
+ "from their right-side twins in the same rotation set, for free; set flipFacings false to animate them too.\n" +
1009
+ "3. No preset, a typed action: one clip per image. A list of actions makes one clip per action (up to 6): " +
1010
+ "'name: description' separated by semicolons names each clip (e.g. 'attack-2: an overhead slash; block: " +
1011
+ "raise the sword to guard'); for one image a plain 'idle, run, jump' also works. Several actions on several " +
1012
+ "images: every image gets every action.\n" +
1013
+ "Cost: 15 credits per clip (mirrored clips are free; clips that fail are refunded). Clips that come back to " +
1014
+ "their start (idle, walk, run, attack, jump, hurt, dash) end on their own first frame, so they loop; one-off " +
1015
+ "clips such as a death do not. Clips are added to their source asset as frames named after the action.",
968
1016
  {
969
1017
  items: z.array(pick).min(1).max(12).describe("Images to animate, in order"),
970
1018
  preset: z
@@ -972,8 +1020,8 @@ export function createVoidlessServer(API_KEY) {
972
1020
  .optional()
973
1021
  .describe("Named set of actions (see get_animation_presets)"),
974
1022
  prompt: z.string().default("").describe("Without a preset: the actions to make, as 'name: description' separated by semicolons. With one: optional extra direction for every clip"),
975
- holdFacing: z.boolean().default(false).describe("End every clip on the frame it started from, so directional loops do not drift"),
976
- flipFacings: z.boolean().default(true).describe("On an 8-way rotation set, mirror the left-side clips from the right-side ones instead of animating them (half the price). One-sided details swap sides, as on the rotation images"),
1023
+ holdFacing: z.boolean().default(false).describe("End every clip on the frame it started from, even one-off clips such as a death (by default loops end on their start and one-offs don't)"),
1024
+ flipFacings: z.boolean().default(true).describe("On an 8-way rotation set, mirror the left-side clips from the right-side ones instead of animating them (mirrored clips are free). One-sided details swap sides, as on the rotation images"),
977
1025
  autoFlip: z.boolean().default(false).describe("After the run, mirror every right-side clip on the asset that has no left twin (animate only the right-facing turns, get all eight). Free"),
978
1026
  },
979
1027
  writeTool("Generate animation set"),
@@ -1070,12 +1118,17 @@ export function createVoidlessServer(API_KEY) {
1070
1118
 
1071
1119
  server.tool(
1072
1120
  "generate_3d_model",
1073
- "Generate a 3D model (GLB) from a prompt. 15 credits (lq) or 60 (hq).",
1121
+ "Generate a 3D model (GLB) from a prompt. 45 credits (game-ready) or 15 (lq draft).",
1074
1122
  {
1075
1123
  prompt: z.string().describe("The model to make"),
1076
1124
  type: z.enum(["object", "character"]).default("object").describe("'character' makes a T-posed figure ready for rigging"),
1077
- style: z.string().default("cartoon style").describe("e.g. 'cartoon style', 'low-poly', 'realistic'"),
1078
- mode: z.enum(["lq", "hq"]).default("hq").describe("lq = fast draft (15 credits), hq = detailed (60 credits)"),
1125
+ style: z.string().default("low-poly").describe(
1126
+ "'low-poly' (default: faceted triangles, flat colours), 'blocky' (voxel art, built from cubes), 'claymation', 'origami', " +
1127
+ "'hand-painted' (stylized fantasy) or 'toy' (glossy vinyl figure)"
1128
+ ),
1129
+ mode: z.enum(["game", "lq", "hq"]).default("game").describe(
1130
+ "game = game-ready, a light clean mesh of about 19k triangles (45 credits); lq = draft (15 credits). hq is an old name for game"
1131
+ ),
1079
1132
  bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Characters only"),
1080
1133
  seed: z.number().int().min(0).default(0),
1081
1134
  },