voidless-mcp 1.6.10 → 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 +102 -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.
|
|
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
|
};
|
|
@@ -478,6 +541,40 @@ 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
|
+
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
|
+
|
|
481
578
|
server.tool(
|
|
482
579
|
"get_asset",
|
|
483
580
|
"Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
|