voidless-mcp 1.6.2 → 1.6.7
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 +2 -1
- package/package.json +2 -1
- package/tools.js +143 -9
package/README.md
CHANGED
|
@@ -74,7 +74,7 @@ claude mcp add voidless -e VOIDLESS_API_KEY=void_... -- npx -y voidless-mcp
|
|
|
74
74
|
|
|
75
75
|
| Tool | What it does |
|
|
76
76
|
| --- | --- |
|
|
77
|
-
| `generate_image` | Sprite, character, portrait, prop, tileset, UI piece or artwork in a chosen model, style, palette, size and perspective |
|
|
77
|
+
| `generate_image` | Sprite, character, portrait, prop, tileset, UI piece or artwork in a chosen model, style, palette, size and perspective; artwork can be 16:9 for backdrops |
|
|
78
78
|
| `edit_image` | Change an image with an instruction; the edit is added as a new frame |
|
|
79
79
|
| `rotate_sprite` | An 8-way set (front, back, sides, diagonals) in one pass |
|
|
80
80
|
| `extract_parts` | Split a character into body parts for rigging |
|
|
@@ -87,6 +87,7 @@ claude mcp add voidless -e VOIDLESS_API_KEY=void_... -- npx -y voidless-mcp
|
|
|
87
87
|
| --- | --- |
|
|
88
88
|
| `animate_sprite` | One 16-frame clip from a description of the motion |
|
|
89
89
|
| `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) |
|
|
90
|
+
| `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 |
|
|
90
91
|
| `flip_left_side` | Mirror the right-side animations into the left side (walk-right -> walk-left), free |
|
|
91
92
|
|
|
92
93
|
**Sound, video and 3D**
|
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.7",
|
|
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>",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
38
|
+
"pngjs": "^7.0.0",
|
|
38
39
|
"undici": "^6.29.0"
|
|
39
40
|
}
|
|
40
41
|
}
|
package/tools.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import pngjs from "pngjs";
|
|
18
|
+
const { PNG } = pngjs;
|
|
17
19
|
import { z } from "zod";
|
|
18
20
|
import { readFileSync } from "node:fs";
|
|
19
21
|
import { Agent, setGlobalDispatcher } from "undici";
|
|
@@ -23,7 +25,7 @@ import { Agent, setGlobalDispatcher } from "undici";
|
|
|
23
25
|
// server does.
|
|
24
26
|
setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
|
|
25
27
|
|
|
26
|
-
export const VERSION = "1.6.
|
|
28
|
+
export const VERSION = "1.6.7";
|
|
27
29
|
const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
|
|
28
30
|
const CDN_URL = "https://cdn.voidless.dev";
|
|
29
31
|
// Where asset links point. Results link to the asset's page in the app rather than to raw GIFs: the
|
|
@@ -286,7 +288,10 @@ export function createVoidlessServer(API_KEY) {
|
|
|
286
288
|
"(the gallery displays it) and let them decide. Do not call a generation tool again to try for a better result, " +
|
|
287
289
|
"to fix a detail, or because the result is not exactly what you pictured — ask the user first, saying what it " +
|
|
288
290
|
"would cost. Re-run only on a clear failure the tool reports (an error), or when the user asks. " +
|
|
289
|
-
"For a character that will be animated, generate it in the 'idle' pose; 'tpose' is only for rigging."
|
|
291
|
+
"For a character that will be animated, generate it in the 'idle' pose; 'tpose' is only for rigging. " +
|
|
292
|
+
"To use Voidless images inside HTML, a game or any code you write (artifacts and code sandboxes often cannot " +
|
|
293
|
+
"download from voidless.dev), call get_image_data: it returns the image, or an animation as a sprite strip, as a " +
|
|
294
|
+
"data URI you can paste in. Do not ask the user to download and attach files.";
|
|
290
295
|
const server = new McpServer({ name: "voidless", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
291
296
|
|
|
292
297
|
// Tool annotations (MCP ToolAnnotations). Generation tools create something new in the caller's
|
|
@@ -361,6 +366,114 @@ export function createVoidlessServer(API_KEY) {
|
|
|
361
366
|
})
|
|
362
367
|
);
|
|
363
368
|
|
|
369
|
+
// ── image data for HTML ──────────────────────────────────────────────────────────────────
|
|
370
|
+
// Claude's HTML artifacts (and many sandboxes) cannot load images from another site, and an image a
|
|
371
|
+
// tool shows the model cannot be copied out of the conversation into code. So this hands back the
|
|
372
|
+
// picture itself as a small data: URI the client pastes into <img src> or a CSS background. Shrunk to
|
|
373
|
+
// fit a text budget: a 512 PNG as base64 is ~400k characters, far past what a reply can carry.
|
|
374
|
+
const DATA_URI_BUDGET = 60000;
|
|
375
|
+
|
|
376
|
+
function decodePng(buf) {
|
|
377
|
+
const png = PNG.sync.read(buf);
|
|
378
|
+
return { width: png.width, height: png.height, data: png.data };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Box-average downscale (keeps pixel art blocky when the factor is whole, smooth art clean otherwise).
|
|
382
|
+
function shrink(img, w, h) {
|
|
383
|
+
if (img.width === w && img.height === h) return img;
|
|
384
|
+
const out = Buffer.alloc(w * h * 4);
|
|
385
|
+
const sx = img.width / w, sy = img.height / h;
|
|
386
|
+
for (let y = 0; y < h; y++) {
|
|
387
|
+
const y0 = Math.floor(y * sy), y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy));
|
|
388
|
+
for (let x = 0; x < w; x++) {
|
|
389
|
+
const x0 = Math.floor(x * sx), x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx));
|
|
390
|
+
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
|
391
|
+
for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) {
|
|
392
|
+
const i = (yy * img.width + xx) * 4, al = img.data[i + 3];
|
|
393
|
+
r += img.data[i] * al; g += img.data[i + 1] * al; b += img.data[i + 2] * al; a += al; n++;
|
|
394
|
+
}
|
|
395
|
+
const o = (y * w + x) * 4;
|
|
396
|
+
if (a) { out[o] = r / a; out[o + 1] = g / a; out[o + 2] = b / a; }
|
|
397
|
+
out[o + 3] = a / n;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { width: w, height: h, data: out };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function encodeStrip(frames, cell) {
|
|
404
|
+
const png = new PNG({ width: cell * frames.length, height: cell });
|
|
405
|
+
frames.forEach((f, k) => {
|
|
406
|
+
const s = shrink(f, cell, cell);
|
|
407
|
+
for (let y = 0; y < cell; y++) s.data.copy(png.data, (y * png.width + k * cell) * 4, y * cell * 4, (y + 1) * cell * 4);
|
|
408
|
+
});
|
|
409
|
+
return PNG.sync.write(png, { colorType: 6 });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function fetchPng(key) {
|
|
413
|
+
const url = cdnUrl(key);
|
|
414
|
+
if (!url) throw new Error(`No public file for ${key}.`);
|
|
415
|
+
const res = await fetch(url);
|
|
416
|
+
if (!res.ok) throw new Error(`Could not load ${url} (${res.status}).`);
|
|
417
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
418
|
+
if (buf.slice(1, 4).toString() !== "PNG") throw new Error(`${url} is not a PNG; use the URL instead.`);
|
|
419
|
+
return decodePng(buf);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
server.tool(
|
|
423
|
+
"get_image_data",
|
|
424
|
+
"An asset's image as a data: URI to put straight into HTML (<img src=…> or a CSS background), for " +
|
|
425
|
+
"sandboxes that cannot load images from another site, such as Claude's HTML artifacts. For an animation " +
|
|
426
|
+
"or rotation frame it returns ONE horizontal sprite strip (all frames side by side, each size×size) plus a " +
|
|
427
|
+
"CSS snippet that plays it. Shrunk to fit a reply (small sizes are fine for sprites; scale them up in CSS " +
|
|
428
|
+
"with image-rendering: pixelated). Free.",
|
|
429
|
+
{
|
|
430
|
+
id: assetId,
|
|
431
|
+
frameId: z.number().int().optional().describe("Frame id from get_asset (an animation, rotation set or edit). Omit for the base image"),
|
|
432
|
+
imageIndex: z.number().int().optional().describe("Only this image of the frame (e.g. one facing of a rotation set) instead of the strip"),
|
|
433
|
+
size: z.number().int().min(16).max(256).default(128).describe("Pixels per image (square). Lowered automatically if the result would be too long"),
|
|
434
|
+
},
|
|
435
|
+
readTool("Get image data"),
|
|
436
|
+
tool(async ({ id, frameId: fid, imageIndex, size }) => {
|
|
437
|
+
const a = await apiGet(`/assets/${id}`);
|
|
438
|
+
const frames = (a.meta_data && a.meta_data.frames) || [];
|
|
439
|
+
let keys;
|
|
440
|
+
let label = "base image";
|
|
441
|
+
if (fid === undefined || fid === null) {
|
|
442
|
+
keys = [a.file];
|
|
443
|
+
} else {
|
|
444
|
+
const f = frames.find((x) => x && x.id === fid) || frames[fid];
|
|
445
|
+
if (!f) throw new Error(`Asset ${id} has no frame ${fid}. Use get_asset for frame ids.`);
|
|
446
|
+
const images = Array.isArray(f.images) ? f.images : [];
|
|
447
|
+
if (!images.length) throw new Error(`Frame ${fid} has no images.`);
|
|
448
|
+
keys = Number.isInteger(imageIndex) ? [images[imageIndex]].filter(Boolean) : images;
|
|
449
|
+
if (!keys.length) throw new Error(`Frame ${fid} has no image ${imageIndex}.`);
|
|
450
|
+
label = `${f.name || f.type || "frame"}${Number.isInteger(imageIndex) ? ` [${imageIndex}]` : ""}`;
|
|
451
|
+
}
|
|
452
|
+
const decoded = await Promise.all(keys.slice(0, 64).map(fetchPng));
|
|
453
|
+
let cell = Math.min(size, Math.max(...decoded.map((d) => Math.max(d.width, d.height))));
|
|
454
|
+
let png = encodeStrip(decoded, cell);
|
|
455
|
+
while (png.length * 4 / 3 > DATA_URI_BUDGET && cell > 16) {
|
|
456
|
+
cell = Math.max(16, Math.floor(cell * 0.75));
|
|
457
|
+
png = encodeStrip(decoded, cell);
|
|
458
|
+
}
|
|
459
|
+
const uri = `data:image/png;base64,${png.toString("base64")}`;
|
|
460
|
+
const n = decoded.length;
|
|
461
|
+
const lines = [
|
|
462
|
+
n === 1
|
|
463
|
+
? `Asset ${id} ${label}: ${cell}×${cell} PNG as a data URI (${uri.length} characters).`
|
|
464
|
+
: `Asset ${id} ${label}: a sprite strip of ${n} frames, each ${cell}×${cell}, total ${cell * n}×${cell} (${uri.length} characters).`,
|
|
465
|
+
n > 1
|
|
466
|
+
? `Play it with CSS: width:${cell}px;height:${cell}px;background:url(<the URI>) 0 0/auto ${cell}px;` +
|
|
467
|
+
`animation:play ${(n / 8).toFixed(2)}s steps(${n}) infinite; @keyframes play{to{background-position:-${cell * n}px 0}}` +
|
|
468
|
+
" (scale with transform:scale(N) and image-rendering:pixelated)."
|
|
469
|
+
: "Use it as <img src=\"<the URI>\" style=\"image-rendering:pixelated\"> or a CSS background.",
|
|
470
|
+
"",
|
|
471
|
+
uri,
|
|
472
|
+
];
|
|
473
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
474
|
+
})
|
|
475
|
+
);
|
|
476
|
+
|
|
364
477
|
server.tool(
|
|
365
478
|
"get_asset",
|
|
366
479
|
"Show one asset with every frame: frame ids, types (animation, rotations - 8-way, edit - text, layer…), " +
|
|
@@ -490,7 +603,7 @@ export function createVoidlessServer(API_KEY) {
|
|
|
490
603
|
"asset TYPE, output SIZE and PERSPECTIVE; for characters also facing/pose/bodyType. Use the default model " +
|
|
491
604
|
"(flux2-klein, 1 credit) unless the user names another. Costs by model: flux2-klein 1, flux-juggernaut 1, " +
|
|
492
605
|
"seedream-5-flash 2, gpt-image-2.5 5, retro-diffusion 10, nano-banana-2 15 credits (a reference image makes it " +
|
|
493
|
-
"
|
|
606
|
+
"2 and uses flux2-klein). Call it once per request and " +
|
|
494
607
|
"show the result; ask the user before generating again (each call spends credits).",
|
|
495
608
|
{
|
|
496
609
|
prompt: z.string().max(300).describe("The subject only (max 300 chars). Put style/colour in the style/palette fields."),
|
|
@@ -502,7 +615,8 @@ export function createVoidlessServer(API_KEY) {
|
|
|
502
615
|
.enum(["object", "character", "character portrait", "artwork", "tileset", "ui"])
|
|
503
616
|
.describe(
|
|
504
617
|
"'character' = full-body sprite (uses facing/pose/bodyType); 'character portrait' = headshot; 'object' = props " +
|
|
505
|
-
"and items; 'artwork' = scenes/backgrounds; 'tileset' =
|
|
618
|
+
"and items; 'artwork' = scenes/backgrounds; 'tileset' = a terrain tile sheet (always GPT-Image, see " +
|
|
619
|
+
"tilesetKind; below 512 it is drawn at 512 and pixelated); 'ui' = interface pieces (see uiMode)."
|
|
506
620
|
),
|
|
507
621
|
dimension: z.enum(["2d", "top down", "isometric"]).default("2d").describe("'2d' = side view, 'top down' = overhead, 'isometric' = 2.5D"),
|
|
508
622
|
size: z
|
|
@@ -531,6 +645,19 @@ export function createVoidlessServer(API_KEY) {
|
|
|
531
645
|
"T-pose (e.g. to rig the character in the skeleton editor)."
|
|
532
646
|
),
|
|
533
647
|
bodyType: z.enum(["humanoid", "quadruped"]).default("humanoid").describe("Characters: body plan"),
|
|
648
|
+
tilesetKind: z
|
|
649
|
+
.enum(["platformer", "blob17", "wang"])
|
|
650
|
+
.optional()
|
|
651
|
+
.describe(
|
|
652
|
+
"type 'tileset' only: 'platformer' = side-view 3x3 ground sheet (default for 2d); 'wang' = top-down two-terrain " +
|
|
653
|
+
"4x5 auto-tile sheet in the free Tileset Generator's layout (default for top down; name both terrains, e.g. " +
|
|
654
|
+
"'a cobblestone path in grass'), every edge matched so any arrangement tiles seamlessly; 'blob17' = top-down " +
|
|
655
|
+
"17-piece sheet of one terrain on transparent. Top-down sheets have 256 px tiles; size 32/64/128 returns pixel tiles"
|
|
656
|
+
),
|
|
657
|
+
aspect: z
|
|
658
|
+
.enum(["1:1", "16:9"])
|
|
659
|
+
.default("1:1")
|
|
660
|
+
.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"),
|
|
534
661
|
uiMode: z
|
|
535
662
|
.enum(["graphic", "title", "text"])
|
|
536
663
|
.default("graphic")
|
|
@@ -540,8 +667,13 @@ export function createVoidlessServer(API_KEY) {
|
|
|
540
667
|
styleImage: z.string().default("").describe("Optional raw base64 image whose art style to copy"),
|
|
541
668
|
},
|
|
542
669
|
writeTool("Generate image"),
|
|
543
|
-
tool(async ({ facing, pose, bodyType, palette, uiMode, ...rest }) => {
|
|
544
|
-
const
|
|
670
|
+
tool(async ({ facing, pose, bodyType, palette, uiMode, tilesetKind, aspect, ...rest }) => {
|
|
671
|
+
const wide = rest.type === "artwork" && aspect === "16:9" ? "16:9" : "1:1";
|
|
672
|
+
const tileset = rest.type === "tileset";
|
|
673
|
+
// Tilesets are GPT-Image only (the server enforces it too); the kind rides in options.mode.
|
|
674
|
+
if (tileset) rest.model = "gpt-image-2.5";
|
|
675
|
+
const mode = tileset ? tilesetKind || "" : uiMode;
|
|
676
|
+
const body = { ...rest, palette: [], options: { facing, pose, bodyType, palette, mode, aspect: wide } };
|
|
545
677
|
return assetResult(await apiPost("/generate/image", body), "Image");
|
|
546
678
|
})
|
|
547
679
|
);
|
|
@@ -556,7 +688,7 @@ export function createVoidlessServer(API_KEY) {
|
|
|
556
688
|
referenceFile,
|
|
557
689
|
frameId,
|
|
558
690
|
images: z.array(z.string()).max(4).default([]).describe("Optional extra reference images: image keys from get_asset or https URLs"),
|
|
559
|
-
remove_bg: z.boolean().default(
|
|
691
|
+
remove_bg: z.boolean().default(true).describe("Remove the background from the result, as the app's editor does. Set false for artwork or a scene that should keep its background"),
|
|
560
692
|
},
|
|
561
693
|
writeTool("Edit image"),
|
|
562
694
|
// Always the app's own editor, the same one as the editor's "Edit this image" box.
|
|
@@ -676,7 +808,9 @@ export function createVoidlessServer(API_KEY) {
|
|
|
676
808
|
"was drawn facing the camera), add {assetId, base:true} as 'down'. If it already has 'down' (a side-view " +
|
|
677
809
|
"sprite), use only the rotation's own images: the base is the side view, not the front. For topdown-idle " +
|
|
678
810
|
"pick only the 4 cardinals (down, right, up, left).\n" +
|
|
679
|
-
"Without a preset,
|
|
811
|
+
"Without a preset, list the actions in the prompt for up to 6 custom clips, one clip each: " +
|
|
812
|
+
"'name: description' separated by semicolons names each clip (e.g. 'attack-2: an overhead slash; block: raise " +
|
|
813
|
+
"the sword to guard'); for one image a plain 'idle, run, jump' also works. Every image gets every action. " +
|
|
680
814
|
"Cost: 100 credits per pass (200 for 1024px sprites, which run in 4K); a pass holds up to 6 clips. On an " +
|
|
681
815
|
"8-way rotation set the left, up-left and down-left clips are mirrored from the right-side ones (the " +
|
|
682
816
|
"rotation's left side is a mirror already), so an 8-way set is ONE pass; set flipFacings false to animate " +
|
|
@@ -687,7 +821,7 @@ export function createVoidlessServer(API_KEY) {
|
|
|
687
821
|
.enum(["platformer-character", "topdown-walk", "topdown-run", "topdown-dash", "topdown-attack", "topdown-idle"])
|
|
688
822
|
.optional()
|
|
689
823
|
.describe("Named set of actions (see get_animation_presets)"),
|
|
690
|
-
prompt: z.string().default("").describe("Without a preset: the actions to make. With one: optional extra direction for every clip"),
|
|
824
|
+
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"),
|
|
691
825
|
holdFacing: z.boolean().default(false).describe("End every clip on the frame it started from, so directional loops do not drift"),
|
|
692
826
|
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"),
|
|
693
827
|
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"),
|