voidless-mcp 1.6.10 → 1.6.12

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 +103 -5
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.10",
4
+ "version": "1.6.12",
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.10";
30
+ export const VERSION = "1.6.12";
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
  };
@@ -478,6 +541,41 @@ export function createVoidlessServer(API_KEY) {
478
541
  })
479
542
  );
480
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
+ const where = st.step ? `${st.step}, about ${Math.round((Number(st.fraction) || 0) * 100)}% — ` : "";
564
+ return { content: [{ type: "text", text: `Still running: ${where}${st.seconds}s so far. Check again in about a minute.` }] };
565
+ }
566
+ if (st.status === "failed") {
567
+ const msg = st.result?.error?.message || st.result?.message || `HTTP ${st.httpStatus}`;
568
+ return { isError: true, content: [{ type: "text", text: `The generation failed: ${msg}` }] };
569
+ }
570
+ const p = pending.get(job);
571
+ if (!p) {
572
+ // This connector restarted since the job began: the raw result is all there is.
573
+ return { content: [{ type: "text", text: `Done. Result:\n${JSON.stringify(st.result).slice(0, 4000)}` }] };
574
+ }
575
+ return replay.run({ path: p.path, result: st.result }, () => p.handler(p.args));
576
+ })
577
+ );
578
+
481
579
  server.tool(
482
580
  "get_asset",
483
581
  "Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +