voidless-mcp 1.6.9 → 1.6.11

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 +1 -0
  2. package/package.json +1 -1
  3. package/tools.js +111 -12
package/README.md CHANGED
@@ -73,6 +73,7 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
73
73
  | --- | --- |
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
+ | `check_generation` | The result of a long generation (animation sets, video, 3D) that was still running when its tool returned. Free |
76
77
  | `get_animation_presets` | The animation-set presets and what each pass costs |
77
78
 
78
79
  **Tagging** (free)
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.9",
4
+ "version": "1.6.11",
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
@@ -18,6 +18,8 @@ import pngjs from "pngjs";
18
18
  const { PNG } = pngjs;
19
19
  import { z } from "zod";
20
20
  import { readFileSync } from "node:fs";
21
+ import { AsyncLocalStorage } from "node:async_hooks";
22
+ import { randomUUID } from "node:crypto";
21
23
  import { Agent, setGlobalDispatcher } from "undici";
22
24
 
23
25
  // Node's fetch gives up after 5 minutes without response headers. Generation calls answer only
@@ -25,7 +27,7 @@ import { Agent, setGlobalDispatcher } from "undici";
25
27
  // server does.
26
28
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
27
29
 
28
- export const VERSION = "1.6.9";
30
+ export const VERSION = "1.6.11";
29
31
  const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
30
32
  const CDN_URL = "https://cdn.voidless.dev";
31
33
  // Where asset links point. Results link to the asset's page in the app rather than to raw GIFs: the
@@ -46,15 +48,36 @@ const GALLERY_MIME = "text/html;profile=mcp-app";
46
48
  const GALLERY_TOOLS = [
47
49
  "generate_image", "upload_image", "edit_image", "rotate_sprite", "animate_sprite", "generate_animation_set",
48
50
  "flip_left_side", "generate_video", "generate_sfx", "generate_music", "generate_3d_model", "get_asset",
49
- "list_assets", "extract_parts", "pixelate_image", "extend_video", "clean_up_sprite",
51
+ "list_assets", "extract_parts", "pixelate_image", "extend_video", "clean_up_sprite", "check_generation",
50
52
  ];
51
53
  const GALLERY_HTML = readFileSync(new URL("./gallery.html", import.meta.url), "utf8");
52
54
 
55
+ // Generations that can outlast Cloudflare's 100 s cut-off (HTTP 524). An animation set takes ~4
56
+ // minutes, so the tool used to report a failure while the job ran on, finished and charged; the
57
+ // assistant then retried and paid twice. These calls carry a job key, wait up to WAIT_MS, and if the
58
+ // job is still going the tool says so and check_generation fetches the result later (Strapi keeps
59
+ // it under the key: GET /generate/job/:key).
60
+ const LONG_PATHS = new Set([
61
+ "/generate/image", "/generate/image-edit", "/generate/image-rotations", "/generate/image-parts",
62
+ "/generate/image-animation", "/generate/multi-animation", "/generate/video", "/generate/video-extend",
63
+ "/generate/sfx", "/generate/music", "/generate/3d-model", "/generate/model-rig", "/generate/model-animation",
64
+ ]);
65
+ const WAIT_MS = Number(process.env.VOIDLESS_WAIT_MS) || 80_000;
66
+ const CUT_OFF = /\((502|504|520|522|524)\)|fetch failed|ECONNRESET|socket|terminated|other side closed/i;
67
+
68
+ class PendingJob extends Error {
69
+ constructor(key, path) {
70
+ super(`Still running: ${key}`);
71
+ this.key = key;
72
+ this.path = path;
73
+ }
74
+ }
75
+
53
76
  export function createVoidlessServer(API_KEY) {
54
- async function api(method, path, body) {
77
+ async function api(method, path, body, headers = {}) {
55
78
  const res = await fetch(`${BASE_URL}/api${path}`, {
56
79
  method,
57
- headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
80
+ headers: { "Content-Type": "application/json", "x-api-key": API_KEY, ...headers },
58
81
  body: body === undefined ? undefined : JSON.stringify(body),
59
82
  });
60
83
 
@@ -74,15 +97,55 @@ export function createVoidlessServer(API_KEY) {
74
97
  }
75
98
 
76
99
  const apiGet = (path) => api("GET", path);
77
- const apiPost = (path, body) => api("POST", path, body);
78
100
  const apiPut = (path, body) => api("PUT", path, body);
79
101
 
102
+ // check_generation re-runs the tool that started a job with its stored result standing in for the
103
+ // slow call, so the reply is exactly what the tool would have returned (see LONG_PATHS).
104
+ const replay = new AsyncLocalStorage();
105
+ const pending = new Map(); // job key -> { handler, args, path }
106
+
107
+ async function apiPost(path, body) {
108
+ const stored = replay.getStore();
109
+ if (stored && stored.path === path) return stored.result;
110
+ if (!LONG_PATHS.has(path)) return api("POST", path, body);
111
+ const key = `mcp-${randomUUID()}`;
112
+ const call = api("POST", path, body, { "X-Idempotency-Key": key }).then((v) => ({ v }), (e) => ({ e }));
113
+ let timer;
114
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), WAIT_MS); });
115
+ try {
116
+ const out = await Promise.race([call, timeout]);
117
+ if (out === null) throw new PendingJob(key, path);
118
+ // The connection was cut but the job may well be running: check, do not report a failure.
119
+ if (out.e) throw CUT_OFF.test(String(out.e.message || out.e)) ? new PendingJob(key, path) : out.e;
120
+ return out.v;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
126
+ function pendingResult(key) {
127
+ return {
128
+ content: [{
129
+ type: "text",
130
+ text: `Still generating — this kind of job can take 2 to 5 minutes. Job: ${key}\n` +
131
+ "It keeps running and its credits are already reserved. Do NOT call this tool again: that would " +
132
+ `make (and charge for) a second one. Call check_generation with job "${key}" in about a minute ` +
133
+ "to get the result.",
134
+ }],
135
+ };
136
+ }
137
+
80
138
  // Tool errors go back to the model as text it can act on, not as a protocol failure.
81
139
  function tool(handler) {
82
140
  return async (args) => {
83
141
  try {
84
142
  return await handler(args);
85
143
  } catch (err) {
144
+ if (err instanceof PendingJob) {
145
+ pending.set(err.key, { handler, args, path: err.path });
146
+ if (pending.size > 500) pending.delete(pending.keys().next().value);
147
+ return pendingResult(err.key);
148
+ }
86
149
  return { isError: true, content: [{ type: "text", text: String(err.message || err) }] };
87
150
  }
88
151
  };
@@ -277,9 +340,13 @@ export function createVoidlessServer(API_KEY) {
277
340
  return item ? [item] : [];
278
341
  }
279
342
  const MAX_GALLERY = 48;
343
+ // Clients that read structuredContent hand the MODEL that object instead of the text content
344
+ // (Claude Code does), so it carries the same text as `summary`: without it the model saw only the
345
+ // gallery and told users "the tool didn't report its cost" and that it had one frame to look at.
346
+ const textOf = (result) => (result.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
280
347
  const withGallery = (result, items, title) => ({
281
348
  ...result,
282
- structuredContent: { title, gallery: items.filter(Boolean).slice(0, MAX_GALLERY) },
349
+ structuredContent: { title, summary: textOf(result), gallery: items.filter(Boolean).slice(0, MAX_GALLERY) },
283
350
  });
284
351
 
285
352
  // Read by clients as the server's standing instructions (MCP `instructions`).
@@ -474,6 +541,40 @@ export function createVoidlessServer(API_KEY) {
474
541
  })
475
542
  );
476
543
 
544
+ server.tool(
545
+ "check_generation",
546
+ "Get the result of a generation that was still running when its tool returned (the tool said " +
547
+ "'Still generating' and gave a job). Call it about a minute later; if it is still running, wait and " +
548
+ "call again. Never re-run the original tool instead: that makes and charges a second one. Free.",
549
+ { job: z.string().min(8).max(100).describe("The job from the 'Still generating' message") },
550
+ readTool("Check generation"),
551
+ tool(async ({ job }) => {
552
+ let st;
553
+ try {
554
+ st = await apiGet(`/generate/job/${encodeURIComponent(job)}`);
555
+ } catch (err) {
556
+ 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." }] };
559
+ }
560
+ throw err;
561
+ }
562
+ if (st.status === "running") {
563
+ return { content: [{ type: "text", text: `Still running (${st.seconds}s so far). Check again in about a minute.` }] };
564
+ }
565
+ if (st.status === "failed") {
566
+ const msg = st.result?.error?.message || st.result?.message || `HTTP ${st.httpStatus}`;
567
+ return { isError: true, content: [{ type: "text", text: `The generation failed: ${msg}` }] };
568
+ }
569
+ const p = pending.get(job);
570
+ if (!p) {
571
+ // This connector restarted since the job began: the raw result is all there is.
572
+ return { content: [{ type: "text", text: `Done. Result:\n${JSON.stringify(st.result).slice(0, 4000)}` }] };
573
+ }
574
+ return replay.run({ path: p.path, result: st.result }, () => p.handler(p.args));
575
+ })
576
+ );
577
+
477
578
  server.tool(
478
579
  "get_asset",
479
580
  "Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
@@ -721,15 +822,14 @@ export function createVoidlessServer(API_KEY) {
721
822
  // The ids returned here are off by (parts - 1): the server numbers from the last appended
722
823
  // frame. Point at get_asset instead of printing ids that would misdirect a follow-up call.
723
824
  const names = (data.frames || []).map((f) => f.name).filter(Boolean);
724
- return {
825
+ return withGallery({
725
826
  content: [
726
827
  {
727
828
  type: "text",
728
829
  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.`,
729
830
  },
730
831
  ],
731
- structuredContent: { title: "Parts", gallery: (data.frames || []).flatMap((f) => frameItems(f, args.id)).slice(0, MAX_GALLERY) },
732
- };
832
+ }, (data.frames || []).flatMap((f) => frameItems(f, args.id)), "Parts");
733
833
  })
734
834
  );
735
835
 
@@ -762,13 +862,12 @@ export function createVoidlessServer(API_KEY) {
762
862
  writeTool("Pixelate image"),
763
863
  tool(async ({ image, ...rest }) => {
764
864
  const data = await apiPost("/pixelate/image", { image: String(image).replace(/^data:[^,]*,/, ""), ...rest });
765
- return {
865
+ return withGallery({
766
866
  content: [
767
867
  { type: "text", text: money(data) || "Pixelated." },
768
868
  { type: "image", data: data.image, mimeType: "image/png" },
769
869
  ],
770
- structuredContent: { title: "Pixel art", gallery: data.image ? [{ kind: "image", url: `data:image/png;base64,${data.image}`, label: "Pixelated" }] : [] },
771
- };
870
+ }, data.image ? [{ kind: "image", url: `data:image/png;base64,${data.image}`, label: "Pixelated" }] : [], "Pixel art");
772
871
  })
773
872
  );
774
873