voidless-mcp 1.6.8 → 1.6.10

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 +2 -1
  2. package/package.json +1 -1
  3. package/tools.js +66 -13
package/README.md CHANGED
@@ -91,7 +91,8 @@ Or just the hosted connector, no key: `claude mcp add --transport http voidless
91
91
  | `rotate_sprite` | An 8-way set (front, back, sides, diagonals) in one pass |
92
92
  | `extract_parts` | Split a character into body parts for rigging |
93
93
  | `upload_image` | Add an image you already have to your library |
94
- | `pixelate_image` | Convert any image to pixel art |
94
+ | `pixelate_image` | Convert any image to pixel art (32 to 1024 px) |
95
+ | `clean_up_sprite` | Make a sprite or a 1-5 s animation from anywhere game-ready: background removed, pixel art on a real grid, stray pixels fixed; videos come back as 16 frames |
95
96
 
96
97
  **Animation**
97
98
 
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.8",
4
+ "version": "1.6.10",
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
@@ -25,7 +25,7 @@ import { Agent, setGlobalDispatcher } from "undici";
25
25
  // server does.
26
26
  setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0, connectTimeout: 30_000 }));
27
27
 
28
- export const VERSION = "1.6.8";
28
+ export const VERSION = "1.6.10";
29
29
  const BASE_URL = process.env.VOIDLESS_API_URL || "https://api.voidless.dev";
30
30
  const CDN_URL = "https://cdn.voidless.dev";
31
31
  // Where asset links point. Results link to the asset's page in the app rather than to raw GIFs: the
@@ -46,7 +46,7 @@ const GALLERY_MIME = "text/html;profile=mcp-app";
46
46
  const GALLERY_TOOLS = [
47
47
  "generate_image", "upload_image", "edit_image", "rotate_sprite", "animate_sprite", "generate_animation_set",
48
48
  "flip_left_side", "generate_video", "generate_sfx", "generate_music", "generate_3d_model", "get_asset",
49
- "list_assets", "extract_parts", "pixelate_image", "extend_video",
49
+ "list_assets", "extract_parts", "pixelate_image", "extend_video", "clean_up_sprite",
50
50
  ];
51
51
  const GALLERY_HTML = readFileSync(new URL("./gallery.html", import.meta.url), "utf8");
52
52
 
@@ -277,9 +277,13 @@ export function createVoidlessServer(API_KEY) {
277
277
  return item ? [item] : [];
278
278
  }
279
279
  const MAX_GALLERY = 48;
280
+ // Clients that read structuredContent hand the MODEL that object instead of the text content
281
+ // (Claude Code does), so it carries the same text as `summary`: without it the model saw only the
282
+ // gallery and told users "the tool didn't report its cost" and that it had one frame to look at.
283
+ const textOf = (result) => (result.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
280
284
  const withGallery = (result, items, title) => ({
281
285
  ...result,
282
- structuredContent: { title, gallery: items.filter(Boolean).slice(0, MAX_GALLERY) },
286
+ structuredContent: { title, summary: textOf(result), gallery: items.filter(Boolean).slice(0, MAX_GALLERY) },
283
287
  });
284
288
 
285
289
  // Read by clients as the server's standing instructions (MCP `instructions`).
@@ -620,9 +624,9 @@ export function createVoidlessServer(API_KEY) {
620
624
  ),
621
625
  dimension: z.enum(["2d", "top down", "isometric"]).default("2d").describe("'2d' = side view, 'top down' = overhead, 'isometric' = 2.5D"),
622
626
  size: z
623
- .union([z.literal(32), z.literal(64), z.literal(128), z.literal(512), z.literal(1024)])
627
+ .union([z.literal(32), z.literal(64), z.literal(128), z.literal(256), z.literal(512), z.literal(1024)])
624
628
  .default(512)
625
- .describe("Output px. 32/64/128 give a pixel-art look; 512 for every other style; 1024 for high-res."),
629
+ .describe("Output px. 32/64/128/256 give a pixel-art look; 512 for every other style; 1024 for high-res."),
626
630
  colours: z.number().int().min(4).max(12).default(8).describe("Colour count for pixel-art reduction (4–12)"),
627
631
  style: z
628
632
  .string()
@@ -721,22 +725,21 @@ export function createVoidlessServer(API_KEY) {
721
725
  // The ids returned here are off by (parts - 1): the server numbers from the last appended
722
726
  // frame. Point at get_asset instead of printing ids that would misdirect a follow-up call.
723
727
  const names = (data.frames || []).map((f) => f.name).filter(Boolean);
724
- return {
728
+ return withGallery({
725
729
  content: [
726
730
  {
727
731
  type: "text",
728
732
  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
733
  },
730
734
  ],
731
- structuredContent: { title: "Parts", gallery: (data.frames || []).flatMap((f) => frameItems(f, args.id)).slice(0, MAX_GALLERY) },
732
- };
735
+ }, (data.frames || []).flatMap((f) => frameItems(f, args.id)), "Parts");
733
736
  })
734
737
  );
735
738
 
736
739
  server.tool(
737
740
  "upload_image",
738
741
  "Add an image the user already has to their library, so it can be animated, rotated or edited. It must " +
739
- "be square at 32, 64, 128, 512 or 1024 px. Free.",
742
+ "be square at 32, 64, 128, 256, 512 or 1024 px. Free.",
740
743
  {
741
744
  image: z.string().describe("Base64 image (a data: prefix is stripped automatically)"),
742
745
  type: z.enum(["object", "character", "character portrait", "artwork"]).default("character"),
@@ -755,19 +758,69 @@ export function createVoidlessServer(API_KEY) {
755
758
  "saved to the library. 1 credit.",
756
759
  {
757
760
  image: z.string().describe("Base64 source image (a data: prefix is stripped automatically)"),
758
- size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(512)]).default(64),
761
+ size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(256), z.literal(512), z.literal(1024)]).default(64)
762
+ .describe("Pixel size. 512 and 1024 are drawn on a real 4x grid (128 / 256 art pixels)"),
759
763
  colours: z.number().int().min(4).max(12).default(8),
760
764
  },
761
765
  writeTool("Pixelate image"),
762
766
  tool(async ({ image, ...rest }) => {
763
767
  const data = await apiPost("/pixelate/image", { image: String(image).replace(/^data:[^,]*,/, ""), ...rest });
764
- return {
768
+ return withGallery({
765
769
  content: [
766
770
  { type: "text", text: money(data) || "Pixelated." },
767
771
  { type: "image", data: data.image, mimeType: "image/png" },
768
772
  ],
769
- structuredContent: { title: "Pixel art", gallery: data.image ? [{ kind: "image", url: `data:image/png;base64,${data.image}`, label: "Pixelated" }] : [] },
770
- };
773
+ }, data.image ? [{ kind: "image", url: `data:image/png;base64,${data.image}`, label: "Pixelated" }] : [], "Pixel art");
774
+ })
775
+ );
776
+
777
+ server.tool(
778
+ "clean_up_sprite",
779
+ "Make a sprite or a short animation made ANYWHERE (another AI tool, an artist, a screen recording) " +
780
+ "game-ready with Voidless's own clean-up: background removed with stray pieces checked (sparks and dust " +
781
+ "kept, smudges removed); pixel art put on a real pixel grid with one palette and stray pixels fixed. A 1-5 s " +
782
+ "video comes back as 16 frames: kind 'loop' cuts the cleanest loop (walk, run, idle), 'once' keeps the " +
783
+ "whole move (attack, jump). Saved to the user's library. Give the file as a public URL (best for videos) or " +
784
+ "base64. 1 free run a day (10 on Voidless+), then 1 credit an image, 2 a video. For a quick pixel-art " +
785
+ "version of an already clean image, pixelate_image is enough.",
786
+ {
787
+ url: z.string().optional().describe("Public http(s) link to the image or video. Use this for videos"),
788
+ file: z.string().optional().describe("Base64 file instead of url (a data: prefix is stripped automatically)"),
789
+ kind: z.enum(["image", "video"]).optional().describe("What the file is. Inferred from the url's extension when omitted"),
790
+ style: z.enum(["pixel", "smooth"]).default("pixel").describe("pixel = pixel art at 32-1024; smooth = clean cut-out at 512 or 1024"),
791
+ size: z.union([z.literal(32), z.literal(64), z.literal(128), z.literal(256), z.literal(512), z.literal(1024)]).optional()
792
+ .describe("Output size. Default 64 for pixel, 512 for smooth (smooth allows only 512 or 1024)"),
793
+ motion: z.enum(["loop", "once"]).default("loop").describe("Video only: loop = find the cleanest loop; once = keep the whole clip"),
794
+ },
795
+ writeTool("Clean up sprite"),
796
+ tool(async ({ url, file, kind, style, size, motion }) => {
797
+ if (!url && !file) throw new Error("Give the file as a url or as base64 in file.");
798
+ const pixel = style !== "smooth";
799
+ const out = size || (pixel ? 64 : 512);
800
+ if (!pixel && out !== 512 && out !== 1024) throw new Error("Smooth art comes back at 512 or 1024.");
801
+ const guessed = /\.(mp4|webm|mov|m4v)(\?|$)/i.test(String(url || "")) ? "video"
802
+ : /^data:video\//i.test(String(file || "")) ? "video" : "image";
803
+ const body = { kind: kind || guessed, size: out, pixel, loop: motion };
804
+ if (url) body.url = url;
805
+ else body.data = String(file).replace(/^data:[^,]*,/, "");
806
+ const data = await apiPost("/cleanup/run", body);
807
+ const link = data.assetId ? `${APP_URL}/asset/${data.assetId}` : undefined;
808
+ const lines = [
809
+ `Cleaned up: ${data.kind === "video" ? `${(data.images || []).length} frames` : "1 sprite"} at ${data.size}px${data.pixel ? " pixel art" : ""}.`,
810
+ data.assetId ? `Asset ID: ${data.assetId} · ${link}` : null,
811
+ data.kind === "video" && Array.isArray(data.span) ? `Frames taken from ${data.span[0]}s to ${data.span[1]}s of the clip.` : null,
812
+ data.free ? `Free run (${data.freeLeft} free left today).` : null,
813
+ money(data),
814
+ ].filter(Boolean);
815
+ const content = [{ type: "text", text: lines.join("\n") }];
816
+ if (data.kind === "video") {
817
+ const frame = { id: 0, name: "Clean-up", type: "animation", images: data.images || [], gif: data.gif };
818
+ content.push(...(await frameContent([frame])));
819
+ return withGallery({ content }, frameItems(frame, data.assetId), "Clean-up");
820
+ }
821
+ const image = await inlineImage(data.file);
822
+ if (image) content.push(image);
823
+ return withGallery({ content }, [mediaItem(data.file, "Cleaned up", { link })], "Clean-up");
771
824
  })
772
825
  );
773
826