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.
- package/README.md +1 -0
- package/package.json +1 -1
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
771
|
-
};
|
|
870
|
+
}, data.image ? [{ kind: "image", url: `data:image/png;base64,${data.image}`, label: "Pixelated" }] : [], "Pixel art");
|
|
772
871
|
})
|
|
773
872
|
);
|
|
774
873
|
|