simplepixelart-mcp 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Comficker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # simplepixelart-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server that gives AI assistants (Claude, Cursor, Windsurf, VS Code…) real pixel art tools: turn any image into pixel art, extract a palette, recolour sprites to PICO-8 or Game Boy colours, and upscale without blur.
4
+
5
+ Runs locally, works on PNG and JPEG files or image URLs, no API keys, no native dependencies.
6
+
7
+ ## Tools
8
+
9
+ | Tool | What it does |
10
+ |---|---|
11
+ | `pixelate_image` | Image → pixel art. Shrinks to a pixel grid (e.g. 64 px wide), reduces to N colours with median cut or maps to a named palette, saves a PNG (optionally upscaled for preview). |
12
+ | `extract_palette` | Pulls the main colours out of an image and returns hex codes plus a GIMP / Aseprite `.gpl` palette. |
13
+ | `remap_palette` | Recolours existing pixel art so every pixel uses a palette colour — PICO-8, Sweetie 16, ENDESGA 32, Game Boy, 1-bit or your own hex list. |
14
+ | `upscale_pixel_art` | Nearest-neighbour upscale by a whole number, so pixels stay sharp. |
15
+ | `list_palettes` | Lists the built-in palettes. |
16
+
17
+ ## Install
18
+
19
+ Add it to your MCP client config. For Claude Desktop (`claude_desktop_config.json`), Cursor (`.cursor/mcp.json`) and most other clients:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "pixel-art": {
25
+ "command": "npx",
26
+ "args": ["-y", "simplepixelart-mcp"]
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Claude Code:
33
+
34
+ ```bash
35
+ claude mcp add pixel-art -- npx -y simplepixelart-mcp
36
+ ```
37
+
38
+ Requires Node.js 18 or newer.
39
+
40
+ ## Examples
41
+
42
+ Ask your assistant things like:
43
+
44
+ - "Turn `~/photos/cat.jpg` into 48 px wide pixel art with the PICO-8 palette, and save an 8x preview."
45
+ - "Extract a 6-colour palette from this screenshot and give me the .gpl file."
46
+ - "Recolour every sprite in `assets/` to the Game Boy palette."
47
+ - "Upscale `hero.png` 10x for the store page."
48
+
49
+ ## Use it as a library
50
+
51
+ ```js
52
+ import { loadImage, pixelate, saveImage } from 'simplepixelart-mcp/src/pixel.js'
53
+
54
+ const img = await loadImage('photo.jpg')
55
+ const { display, palette } = pixelate(img, { width: 64, palette: 'sweetie-16', scale: 8 })
56
+ await saveImage(display, 'photo-pixel.png')
57
+ ```
58
+
59
+ ## Want to edit the result?
60
+
61
+ The tools get you a clean starting grid; the fun part is fixing it by hand. Open the PNG in [SimplePixelArt](https://simplepixelart.com/), a free online [pixel art editor](https://simplepixelart.com/editor) with layers, animation and tileset tools, or try its in-browser [pixel art converter](https://simplepixelart.com/converter) for live adjustments. No account needed.
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ npm install
67
+ npm test # starts the server over stdio and calls every tool
68
+ ```
69
+
70
+ ## License
71
+
72
+ MIT © Comficker
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "simplepixelart-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "MCP server for pixel art: turn images into pixel art, extract and remap palettes (PICO-8, Game Boy, ENDESGA 32…), and upscale sprites without blur.",
5
+ "type": "module",
6
+ "bin": { "simplepixelart-mcp": "src/index.js" },
7
+ "main": "src/pixel.js",
8
+ "files": ["src", "README.md", "LICENSE"],
9
+ "scripts": { "start": "node src/index.js", "test": "node test/run.js" },
10
+ "keywords": ["mcp", "model-context-protocol", "pixel-art", "pixelate", "palette", "sprite", "gamedev", "image-to-pixel-art", "pico-8", "claude", "cursor"],
11
+ "homepage": "https://simplepixelart.com/",
12
+ "repository": { "type": "git", "url": "git+https://github.com/comficker/pixel-art-mcp.git" },
13
+ "bugs": { "url": "https://github.com/comficker/pixel-art-mcp/issues" },
14
+ "author": "Comficker (https://simplepixelart.com)",
15
+ "license": "MIT",
16
+ "engines": { "node": ">=18" },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.12.0",
19
+ "jpeg-js": "^0.4.4",
20
+ "pngjs": "^7.0.0",
21
+ "zod": "^3.24.0"
22
+ }
23
+ }
package/src/index.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // MCP server (stdio) exposing the pixel art operations in pixel.js as tools.
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
+ import { z } from 'zod'
6
+ import { basename, dirname, extname, join } from 'node:path'
7
+ import { tmpdir } from 'node:os'
8
+ import {
9
+ PALETTES, loadImage, saveImage, pixelate, medianCut, downscale, remap, resolvePalette, upscale, usedColors, rgbToHex, toGpl,
10
+ } from './pixel.js'
11
+
12
+ const server = new McpServer({ name: 'simplepixelart-mcp', version: '0.1.0' })
13
+
14
+ /** Default output next to a local input (or in the temp dir for URLs). */
15
+ function outPath(input, suffix, output) {
16
+ if (output) return output
17
+ const dir = /^https?:\/\//.test(input) ? tmpdir() : dirname(input)
18
+ const name = basename(input.split('?')[0], extname(input.split('?')[0])) || 'image'
19
+ return join(dir, `${name}-${suffix}.png`)
20
+ }
21
+ const text = t => ({ content: [{ type: 'text', text: t }] })
22
+ const paletteHelp = `Palette name (${Object.keys(PALETTES).join(', ')}) or hex colours like "#1a1c2c,#5d275d"`
23
+
24
+ server.registerTool('pixelate_image', {
25
+ title: 'Image to pixel art',
26
+ description: 'Turn a photo or illustration into pixel art: shrink it to a pixel grid, reduce it to a few colours (or a named palette) and save a PNG. Accepts a local PNG/JPEG path or an http(s) URL.',
27
+ inputSchema: {
28
+ input: z.string().describe('Path or URL of a PNG or JPEG image'),
29
+ width: z.number().int().min(4).max(512).default(64).describe('Width of the pixel grid in pixels (height keeps the aspect ratio)'),
30
+ colors: z.number().int().min(2).max(64).default(16).describe('Number of colours when no palette is given'),
31
+ palette: z.string().optional().describe(paletteHelp),
32
+ scale: z.number().int().min(1).max(32).default(1).describe('Upscale factor for the saved PNG, nearest neighbour (e.g. 8 for a crisp preview)'),
33
+ output: z.string().optional().describe('Where to save the PNG (defaults next to the input)'),
34
+ },
35
+ }, async ({ input, width, colors, palette, scale, output }) => {
36
+ const img = await loadImage(input)
37
+ const r = pixelate(img, { width, colors, palette, scale })
38
+ const path = await saveImage(r.display, outPath(input, `pixel-${r.pixel.width}x${r.pixel.height}`, output))
39
+ return text(`Saved ${r.pixel.width}x${r.pixel.height} pixel art (${r.palette.length} colours${scale > 1 ? `, shown at ${scale}x` : ''}) to ${path}\nPalette: ${r.palette.join(' ')}\nEdit it further in the free online pixel art editor: https://simplepixelart.com/editor`)
40
+ })
41
+
42
+ server.registerTool('extract_palette', {
43
+ title: 'Extract palette',
44
+ description: 'Pull the main colours out of an image (median cut). Returns hex codes plus a GIMP/Aseprite .gpl palette.',
45
+ inputSchema: {
46
+ input: z.string().describe('Path or URL of a PNG or JPEG image'),
47
+ colors: z.number().int().min(2).max(64).default(8).describe('How many colours to extract'),
48
+ },
49
+ }, async ({ input, colors }) => {
50
+ const img = downscale(await loadImage(input), 256)
51
+ const hexes = medianCut(img, colors).map(rgbToHex)
52
+ return text(`${hexes.join(' ')}\n\n${toGpl(hexes, basename(input))}`)
53
+ })
54
+
55
+ server.registerTool('remap_palette', {
56
+ title: 'Recolour to a palette',
57
+ description: 'Recolour an existing pixel art image so every pixel uses a colour from the given palette (e.g. PICO-8 or Game Boy). Size is kept.',
58
+ inputSchema: {
59
+ input: z.string().describe('Path or URL of a PNG or JPEG image'),
60
+ palette: z.string().describe(paletteHelp),
61
+ output: z.string().optional().describe('Where to save the PNG'),
62
+ },
63
+ }, async ({ input, palette, output }) => {
64
+ const img = remap(await loadImage(input), resolvePalette(palette))
65
+ const path = await saveImage(img, outPath(input, 'remap', output))
66
+ return text(`Saved ${img.width}x${img.height} image using ${usedColors(img).length} colours to ${path}`)
67
+ })
68
+
69
+ server.registerTool('upscale_pixel_art', {
70
+ title: 'Upscale without blur',
71
+ description: 'Scale pixel art up by a whole number with nearest neighbour, so pixels stay sharp (for previews, store pages or social posts).',
72
+ inputSchema: {
73
+ input: z.string().describe('Path or URL of a PNG image'),
74
+ scale: z.number().int().min(2).max(64).default(8),
75
+ output: z.string().optional(),
76
+ },
77
+ }, async ({ input, scale, output }) => {
78
+ const img = upscale(await loadImage(input), scale)
79
+ const path = await saveImage(img, outPath(input, `x${scale}`, output))
80
+ return text(`Saved ${img.width}x${img.height} to ${path}`)
81
+ })
82
+
83
+ server.registerTool('list_palettes', {
84
+ title: 'List built-in palettes',
85
+ description: 'List the built-in pixel art palettes and their colours.',
86
+ inputSchema: {},
87
+ }, async () => text(Object.entries(PALETTES).map(([k, v]) => `${k} (${v.split(' ').length}): ${v.split(' ').map(h => '#' + h).join(' ')}`).join('\n') +
88
+ '\n\nBrowse thousands more palettes at https://simplepixelart.com/palettes'))
89
+
90
+ await server.connect(new StdioServerTransport())
package/src/pixel.js ADDED
@@ -0,0 +1,166 @@
1
+ // Pixel art image operations on plain RGBA buffers: load/save PNG and JPEG,
2
+ // downscale by area averaging, median-cut quantise, palette remap and
3
+ // nearest-neighbour upscale. No native dependencies.
4
+ import { readFile, writeFile } from 'node:fs/promises'
5
+ import { PNG } from 'pngjs'
6
+ import jpeg from 'jpeg-js'
7
+
8
+ export const PALETTES = {
9
+ 'pico-8': '000000 1D2B53 7E2553 008751 AB5236 5F574F C2C3C7 FFF1E8 FF004D FFA300 FFEC27 00E436 29ADFF 83769C FF77A8 FFCCAA',
10
+ 'sweetie-16': '1A1C2C 5D275D B13E53 EF7D57 FFCD75 A7F070 38B764 257179 29366F 3B5DC9 41A6F6 73EFF7 F4F4F4 94B0C2 566C86 333C57',
11
+ 'endesga-32': 'BE4A2F D77643 EAD4AA E4A672 B86F50 733E39 3E2731 A22633 E43B44 F77622 FEAE34 FEE761 63C74D 3E8948 265C42 193C3E 124E89 0099DB 2CE8F5 FFFFFF C0CBDC 8B9BB4 5A6988 3A4466 262B44 181425 FF0044 68386C B55088 F6757A E8B796 C28569',
12
+ 'game-boy': '0F380F 306230 8BAC0F 9BBC0F',
13
+ '1-bit': '000000 FFFFFF',
14
+ }
15
+
16
+ const hexToRgb = h => [0, 2, 4].map(i => parseInt(h.replace('#', '').slice(i, i + 2), 16))
17
+ export const rgbToHex = c => '#' + c.map(v => v.toString(16).padStart(2, '0')).join('')
18
+
19
+ /** Palette from a known name or a list of hex colours. */
20
+ export function resolvePalette(p) {
21
+ if (!p) return null
22
+ if (Array.isArray(p)) return p.map(hexToRgb)
23
+ const known = PALETTES[String(p).toLowerCase()]
24
+ if (known) return known.split(' ').map(hexToRgb)
25
+ const list = String(p).split(/[\s,]+/).filter(Boolean)
26
+ if (list.every(h => /^#?[0-9a-f]{6}$/i.test(h))) return list.map(hexToRgb)
27
+ throw new Error(`Unknown palette "${p}". Use one of ${Object.keys(PALETTES).join(', ')} or a list of hex colours.`)
28
+ }
29
+
30
+ /** Load a PNG or JPEG from a path or http(s) URL into { width, height, data: RGBA }. */
31
+ export async function loadImage(input) {
32
+ let buf
33
+ if (/^https?:\/\//.test(input)) {
34
+ const res = await fetch(input)
35
+ if (!res.ok) throw new Error(`Could not fetch ${input}: HTTP ${res.status}`)
36
+ buf = Buffer.from(await res.arrayBuffer())
37
+ } else {
38
+ buf = await readFile(input)
39
+ }
40
+ if (buf[0] === 0x89 && buf[1] === 0x50) {
41
+ const png = PNG.sync.read(buf)
42
+ return { width: png.width, height: png.height, data: new Uint8ClampedArray(png.data) }
43
+ }
44
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
45
+ const j = jpeg.decode(buf, { useTArray: true, formatAsRGBA: true })
46
+ return { width: j.width, height: j.height, data: new Uint8ClampedArray(j.data) }
47
+ }
48
+ throw new Error('Unsupported image format: only PNG and JPEG are supported.')
49
+ }
50
+
51
+ export async function saveImage(img, path) {
52
+ const png = new PNG({ width: img.width, height: img.height })
53
+ png.data = Buffer.from(img.data)
54
+ await writeFile(path, PNG.sync.write(png))
55
+ return path
56
+ }
57
+
58
+ /** Downscale to `width` pixels wide (aspect kept) by averaging each source block. */
59
+ export function downscale(img, width) {
60
+ const w = Math.max(1, Math.min(width, img.width))
61
+ const h = Math.max(1, Math.round(img.height * w / img.width))
62
+ const out = new Uint8ClampedArray(w * h * 4)
63
+ const sx = img.width / w, sy = img.height / h
64
+ for (let y = 0; y < h; y++) {
65
+ for (let x = 0; x < w; x++) {
66
+ const x0 = Math.floor(x * sx), x1 = Math.max(x0 + 1, Math.floor((x + 1) * sx))
67
+ const y0 = Math.floor(y * sy), y1 = Math.max(y0 + 1, Math.floor((y + 1) * sy))
68
+ let r = 0, g = 0, b = 0, a = 0, n = 0
69
+ for (let yy = y0; yy < y1; yy++) {
70
+ for (let xx = x0; xx < x1; xx++) {
71
+ const i = (yy * img.width + xx) * 4, al = img.data[i + 3]
72
+ r += img.data[i] * al; g += img.data[i + 1] * al; b += img.data[i + 2] * al; a += al; n++
73
+ }
74
+ }
75
+ const o = (y * w + x) * 4
76
+ if (a > 0) { out[o] = r / a; out[o + 1] = g / a; out[o + 2] = b / a }
77
+ out[o + 3] = a / n >= 128 ? 255 : 0 // pixel art is hard-edged: no partial alpha
78
+ }
79
+ }
80
+ return { width: w, height: h, data: out }
81
+ }
82
+
83
+ const opaque = img => {
84
+ const px = []
85
+ for (let i = 0; i < img.data.length; i += 4) if (img.data[i + 3] >= 128) px.push([img.data[i], img.data[i + 1], img.data[i + 2]])
86
+ return px
87
+ }
88
+
89
+ /** Median cut: split the colour box with the widest channel range until `n` boxes. */
90
+ export function medianCut(img, n) {
91
+ const px = opaque(img)
92
+ if (!px.length) return []
93
+ let boxes = [px]
94
+ while (boxes.length < n) {
95
+ let best = -1, bi = -1, ch = 0
96
+ boxes.forEach((b, i) => {
97
+ if (b.length < 2) return
98
+ for (let c = 0; c < 3; c++) {
99
+ let lo = 255, hi = 0
100
+ for (const p of b) { if (p[c] < lo) lo = p[c]; if (p[c] > hi) hi = p[c] }
101
+ if (hi - lo > best) { best = hi - lo; bi = i; ch = c }
102
+ }
103
+ })
104
+ if (bi < 0 || best === 0) break
105
+ const b = boxes[bi].sort((p, q) => p[ch] - q[ch])
106
+ const mid = b.length >> 1
107
+ boxes.splice(bi, 1, b.slice(0, mid), b.slice(mid))
108
+ }
109
+ return boxes.map(b => [0, 1, 2].map(c => Math.round(b.reduce((s, p) => s + p[c], 0) / b.length)))
110
+ }
111
+
112
+ const dist = (a, b) => {
113
+ // Weighted RGB distance: close enough to perceptual for small palettes.
114
+ const r = (a[0] + b[0]) / 2, dr = a[0] - b[0], dg = a[1] - b[1], db = a[2] - b[2]
115
+ return (2 + r / 256) * dr * dr + 4 * dg * dg + (2 + (255 - r) / 256) * db * db
116
+ }
117
+
118
+ /** Map every opaque pixel to its nearest palette colour. */
119
+ export function remap(img, palette) {
120
+ const out = new Uint8ClampedArray(img.data)
121
+ const cache = new Map()
122
+ for (let i = 0; i < out.length; i += 4) {
123
+ if (out[i + 3] < 128) { out[i + 3] = 0; continue }
124
+ const key = (out[i] << 16) | (out[i + 1] << 8) | out[i + 2]
125
+ let c = cache.get(key)
126
+ if (!c) {
127
+ const p = [out[i], out[i + 1], out[i + 2]]
128
+ c = palette.reduce((best, q) => dist(p, q) < dist(p, best) ? q : best, palette[0])
129
+ cache.set(key, c)
130
+ }
131
+ out[i] = c[0]; out[i + 1] = c[1]; out[i + 2] = c[2]; out[i + 3] = 255
132
+ }
133
+ return { width: img.width, height: img.height, data: out }
134
+ }
135
+
136
+ export function upscale(img, scale) {
137
+ const s = Math.max(1, Math.floor(scale))
138
+ const w = img.width * s, h = img.height * s
139
+ const out = new Uint8ClampedArray(w * h * 4)
140
+ const src = new Uint32Array(img.data.buffer, img.data.byteOffset, img.width * img.height)
141
+ const dst = new Uint32Array(out.buffer)
142
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) dst[y * w + x] = src[((y / s) | 0) * img.width + ((x / s) | 0)]
143
+ return { width: w, height: h, data: out }
144
+ }
145
+
146
+ /** Colours actually present, most used first. */
147
+ export function usedColors(img) {
148
+ const n = new Map()
149
+ for (let i = 0; i < img.data.length; i += 4) {
150
+ if (img.data[i + 3] < 128) continue
151
+ const h = rgbToHex([img.data[i], img.data[i + 1], img.data[i + 2]])
152
+ n.set(h, (n.get(h) || 0) + 1)
153
+ }
154
+ return [...n.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0])
155
+ }
156
+
157
+ /** Image → pixel art: downscale, quantise (or map to a palette), optionally upscale for display. */
158
+ export function pixelate(img, { width = 64, colors = 16, palette, scale = 1 } = {}) {
159
+ let out = downscale(img, width)
160
+ const pal = resolvePalette(palette) || medianCut(out, colors)
161
+ out = remap(out, pal)
162
+ return { pixel: out, display: scale > 1 ? upscale(out, scale) : out, palette: usedColors(out) }
163
+ }
164
+
165
+ export const toGpl = (hexes, name = 'Palette') =>
166
+ `GIMP Palette\nName: ${name}\nColumns: 8\n#\n` + hexes.map(h => { const [r, g, b] = hexToRgb(h); return `${String(r).padStart(3)} ${String(g).padStart(3)} ${String(b).padStart(3)}\t${h}` }).join('\n') + '\n'