mcp-server-pixelcade 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,321 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-server-pixelcade
3
+ Version: 0.1.0
4
+ Summary: MCP server for Pixelcade LED marquees and Pixelcade Pulse lighting, via the pixelweb REST API
5
+ Author: Nicholas E. Andrade
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/SDNick484/mcp-server-pixelcade
8
+ Project-URL: Issues, https://github.com/SDNick484/mcp-server-pixelcade/issues
9
+ Project-URL: Changelog, https://github.com/SDNick484/mcp-server-pixelcade/blob/main/CHANGELOG.md
10
+ Keywords: mcp,mcp-server,pixelcade,arcade,batocera,led-marquee
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Games/Entertainment :: Arcade
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: mcp<3,>=2.3
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pyjwt[crypto]>=2.10
26
+ Requires-Dist: uvicorn>=0.31
27
+ Requires-Dist: typing_extensions>=4.7
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8; extra == "dev"
30
+ Requires-Dist: anyio>=4; extra == "dev"
31
+ Requires-Dist: ruff>=0.6; extra == "dev"
32
+ Requires-Dist: mypy>=1.11; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # mcp-server-pixelcade
36
+
37
+ An [MCP](https://modelcontextprotocol.io) server for **[Pixelcade](https://pixelcade.org) LED marquees**
38
+ and **Pixelcade Pulse** lighting (WS2812B strips, speaker rings and matrices on a V2 board). It lets an
39
+ MCP client such as Claude show game and system marquees, scroll text, run arcade-themed clocks and other
40
+ widgets, and drive Pulse lighting effects.
41
+
42
+ > **Status: early, tested on hardware except Pulse.** On a real V2 board (pixelweb 8.0.17, Batocera 43)
43
+ > every marquee, text, widget and frontend tool has been checked, plus dry run; Pulse waits for strips. See
44
+ > [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-pixelcade/blob/main/HARDWARE_VALIDATION.md) and the [verification table](#verification-status).
45
+
46
+ This is not an official Pixelcade project.
47
+
48
+ ## How it works
49
+
50
+ The Pixelcade installer runs **pixelweb**, a small HTTP listener, on the arcade host (on Batocera:
51
+ `/userdata/system/pixelcade/pixelweb`, port 8080, started from `/userdata/system/custom.sh`). Pixelweb
52
+ drives the marquee over USB and exposes a [REST API](https://pixelcade.org/developers). This server is a
53
+ typed, allow-listed client for that API, so it can run anywhere that can reach the arcade on TCP 8080:
54
+ your desktop, a laptop, or a homelab VM. Clients reach it over stdio, or over Streamable HTTP as a
55
+ service behind Cloudflare Access ([Service and Cloudflare](#service-and-cloudflare)).
56
+
57
+ ```
58
+ MCP client ──stdio──▶ mcp-server-pixelcade ──HTTP :8080──▶ pixelweb (Batocera) ──USB──▶ V2 board ─▶ LED panels
59
+ └──▶ Pulse strips
60
+ ```
61
+
62
+ ## Install
63
+
64
+ ```sh
65
+ python -m venv ~/.venvs/pixelcade && . ~/.venvs/pixelcade/bin/activate
66
+ pip install mcp-server-pixelcade
67
+ ```
68
+
69
+ From source instead (for the newest code, or to run the tests):
70
+
71
+ ```sh
72
+ git clone https://github.com/SDNick484/mcp-server-pixelcade.git && cd mcp-server-pixelcade
73
+ python -m venv .venv && . .venv/bin/activate
74
+ pip install -e ".[dev]" && pytest
75
+ ```
76
+
77
+ Requires Python 3.11+. Then check that pixelweb answers:
78
+
79
+ ```sh
80
+ PIXELCADE_URL=192.168.1.20 mcp-server-pixelcade doctor
81
+ ```
82
+
83
+ `doctor` checks each layer (TCP, `/info`, the frontend's current game, Pulse) and, when one fails, says
84
+ what to try and which [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-pixelcade/blob/main/HARDWARE_VALIDATION.md) step covers it. It changes nothing
85
+ on the marquee. It ends with the [assumptions](#verification-status) that hardware contradicted (listed; the code
86
+ no longer relies on them) and a count of those not yet confirmed; `doctor --verbose` lists those too, and
87
+ `doctor --json` always includes both lists.
88
+
89
+ ### Trying it without the arcade
90
+
91
+ ```sh
92
+ mcp-server-pixelcade simulate --write-config /tmp/pxsim # a fake pixelweb; prints every request it gets
93
+ export PIXELCADE_CONFIG_DIR=/tmp/pxsim # in another terminal
94
+ mcp-server-pixelcade doctor
95
+ mcp-server-pixelcade call show_text text="GAME NIGHT" color=cyan # one tool call, as the model makes it
96
+ mcp-server-pixelcade call --dry-run blank_display # reports the GET it would send
97
+ ```
98
+
99
+ ### On Alpine (e.g. a Proxmox LXC)
100
+
101
+ `deploy/alpine/` installs it on an always-on Alpine machine on the same LAN as the arcade, in the
102
+ sibling servers' `deploy/alpine/` layout so they can share an LXC: a venv in `/opt/mcp-server-pixelcade`,
103
+ a `mcp-pixelcade` system user, every setting in `/etc/mcp-server-pixelcade/env`, an OpenRC service
104
+ (`/etc/init.d/mcp-server-pixelcade`), and `mcp-server-pixelcade` on `PATH`, which runs as that user with
105
+ those settings, for `doctor` and `call` from a shell.
106
+
107
+ ```sh
108
+ git clone https://github.com/SDNick484/mcp-server-pixelcade.git && cd mcp-server-pixelcade
109
+ sh deploy/alpine/install.sh # as root; re-run to upgrade
110
+ vim /etc/mcp-server-pixelcade/env # PIXELCADE_URL=<the arcade's IP>
111
+ mcp-server-pixelcade doctor
112
+ mcp-server-pixelcade call show_text text="GAME NIGHT" color=cyan
113
+ rc-update add mcp-server-pixelcade default && rc-service mcp-server-pixelcade start
114
+ ```
115
+
116
+ A variable set in your shell beats the env file, so one-off overrides work as usual
117
+ (`PIXELCADE_DRY_RUN=1 mcp-server-pixelcade call ...`). mcp-server-batocera's
118
+ `deploy/alpine/setup-arcade.sh` installs both arcade servers in a Proxmox container in one go, and
119
+ enables and restarts both services. CI runs `deploy/alpine/smoke-test.sh` in a stock
120
+ `python:3.12-alpine` container: install, upgrade, the test suite on musl, `doctor` and `call` through the
121
+ command on `PATH` against the simulator, and the OpenRC service (as its user, an MCP call over HTTP,
122
+ Access settings from the env file).
123
+
124
+ ### Service and Cloudflare
125
+
126
+ The service runs `/opt/mcp-server-pixelcade/run.sh serve --http` under `supervise-daemon` (restarted if
127
+ it exits) as `mcp-pixelcade`, logging to `/var/log/mcp-server-pixelcade/server.log`. The HTTP transport
128
+ is `remote.py`, shared byte for byte with the sibling servers through
129
+ [mcp-server-shared](https://github.com/SDNick484/mcp-server-shared). Its settings are the `MCP_HTTP_*`,
130
+ `MCP_PUBLIC_HOSTS` and `CF_ACCESS_*` lines in `/etc/mcp-server-pixelcade/env`:
131
+
132
+ | Flag | Variable | Default |
133
+ | --- | --- | --- |
134
+ | `--bind` | `MCP_HTTP_BIND` | `127.0.0.1` |
135
+ | `--port` | `MCP_HTTP_PORT` | `8718` (the siblings use 8711 to 8716, Batocera 8717) |
136
+ | `--path` | `MCP_HTTP_PATH` | `/pixelcade/mcp` |
137
+ | `--public-host` | `MCP_PUBLIC_HOSTS` | none: only loopback Host headers pass |
138
+ | | `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` | none: serves only this machine |
139
+ | | `MCP_ALLOWED_EMAILS` | anyone Access lets in |
140
+
141
+ With Access configured, every request needs a valid Access assertion, checked here: signature (Access's
142
+ published keys), the application's audience tag, issuer, expiry, and the email allow-list if set; anything
143
+ else gets a plain 403. Without Access it serves only the machine it runs on: it won't listen on anything
144
+ but loopback, won't start with a `--public-host`, and answers 403 to anything a tunnel or proxy forwarded
145
+ (`--insecure-no-auth` turns that off for a short trusted-LAN test; don't run the service with it).
146
+
147
+ To use it as a connector, with cloudflared running on the same machine (keep the `127.0.0.1` bind):
148
+
149
+ 1. **Tunnel route:** a public hostname, say `mcp.example.com`, path `pixelcade/*`, to
150
+ `http://localhost:8718`. Several servers can share the hostname, one path each.
151
+ 2. **Access application** (self-hosted) covering that hostname and path, with a policy for who may use it
152
+ and Managed OAuth turned on, so Claude's connector can sign in through Access. Note its AUD tag.
153
+ 3. **The env file:** `MCP_PUBLIC_HOSTS=mcp.example.com`, `CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com`,
154
+ `CF_ACCESS_AUD=<the AUD tag>`, then `rc-service mcp-server-pixelcade restart`.
155
+ 4. **The connector:** `https://mcp.example.com/pixelcade/mcp`.
156
+
157
+ `GET /healthz` answers without auth, for monitoring. Details are in `src/pixelcade_mcp/remote.py`.
158
+
159
+ ## Use it with an MCP client
160
+
161
+ ```json
162
+ {
163
+ "mcpServers": {
164
+ "pixelcade": {
165
+ "command": "/path/to/mcp-server-pixelcade/.venv/bin/mcp-server-pixelcade",
166
+ "env": { "PIXELCADE_URL": "192.168.1.50" }
167
+ }
168
+ }
169
+ }
170
+ ```
171
+
172
+ For Claude Code: `claude mcp add pixelcade -e PIXELCADE_URL=192.168.1.50 -- /path/to/.venv/bin/mcp-server-pixelcade`.
173
+
174
+ Running as a service behind Cloudflare Access instead, add `https://<your host>/pixelcade/mcp` as a
175
+ connector ([Service and Cloudflare](#service-and-cloudflare)).
176
+
177
+ ## Configuration
178
+
179
+ | Setting | Default | What it does |
180
+ | -------------------------------- | ------------------------------------- | -------------------------------------------------------------- |
181
+ | `PIXELCADE_URL` | `http://batocera.local:8080` | Where pixelweb listens. A bare host or IP gets `http://` and `:8080` |
182
+ | `PIXELCADE_PULSE_MAX_BRIGHTNESS` | `128` | Highest Pulse brightness/intensity the model may set (0-255) |
183
+ | `PIXELCADE_TIMEOUT` | `10` | Seconds to wait for pixelweb (1-60) |
184
+ | `PIXELCADE_CONFIG_DIR` | `$XDG_CONFIG_HOME/mcp-server-pixelcade` | Where an optional `config.json` lives |
185
+ | `PIXELCADE_NAME` | pixelweb's URL | How results name the marquee |
186
+ | `PIXELCADE_DRY_RUN` | off | Read pixelweb, change nothing (`serve --dry-run`) |
187
+ | `PIXELCADE_LOG_UNREDACTED` | off | Show LAN addresses in logs (`--no-redact`); secrets stay hidden |
188
+ | `PIXELCADE_DEBUG` | off | Debug logging (`--debug`) |
189
+
190
+ `config.json` can hold the same settings as `url`, `pulse_max_brightness`, `timeout_s`, `name` and `dry_run`; environment
191
+ variables win. `.local` names need mDNS, which usually doesn't cross VLANs or work in WSL2 without
192
+ mirrored networking. A DHCP reservation plus the IP address is the dependable choice.
193
+
194
+ ## Tools
195
+
196
+ All tools use the `pixelcade_` prefix to eliminate namespace collisions with sibling MCP servers in multi-server agent environments. The CLI supports shorthand names (`call show_text` resolves to `pixelcade_show_text`).
197
+
198
+ | Tool | What it does |
199
+ | -------------------------------- | ---------------------------------------------------------------------------------------------- |
200
+ | `pixelcade_get_status` | Reachability, hardware/firmware/software versions, the frontend's selected game, Pulse state |
201
+ | `pixelcade_list_artwork` | The marquees in one folder (a system, `console`, `animations`, `user`): names, still/animated |
202
+ | `pixelcade_list_pulse_effects` | Pulse effect names by category, per-game/per-system name patterns, and zone names |
203
+ | `pixelcade_show_game_marquee` | A game's marquee by system + ROM name; still/animated choice, optional queueing and fallback text |
204
+ | `pixelcade_show_console_marquee` | A system marquee, e.g. `snes` |
205
+ | `pixelcade_show_current_game` | Put back the marquee for the game selected in EmulationStation |
206
+ | `pixelcade_show_default_marquee` | Pixelcade's default marquee (needs pixelweb 8.0.14+) |
207
+ | `pixelcade_show_text` | Scrolling or fixed text: color, speed, loops, 1/2/4 lines, blink, left-justify |
208
+ | `pixelcade_play_animation` | One of Pixelcade's built-in animations |
209
+ | `pixelcade_show_widget` | Clock (19 arcade themes), weather, stocks, sports scores, or the attract-mode playlist |
210
+ | `pixelcade_stop_widgets` | Stop widgets/attract mode and return to arcade mode |
211
+ | `pixelcade_blank_display` | Black screen |
212
+ | `pixelcade_pulse_effect` | Run an effect on a Pulse zone (`rainbow`, `fire`, `mame_pacman`, …) with colors, speed, loops |
213
+ | `pixelcade_pulse_fill` | Solid color on a zone |
214
+ | `pixelcade_pulse_clear` | Turn off one zone, or all LEDs |
215
+ | `pixelcade_set_pulse_brightness` | Overall Pulse brightness, up to the server's cap |
216
+
217
+ **The frontend drives the marquee too.** EmulationStation's Pixelcade hooks call pixelweb whenever you
218
+ scroll to a system or game, and every game launch calls `/attract/stop`. So whatever the model shows
219
+ lasts until the next frontend event. `show_current_game` restores the frontend's marquee, and
220
+ `show_default_marquee` shows the default one.
221
+
222
+ **Locks.** `show_widget(lock=true)` sends `?nointerrupt`, which Pixelcade documents as preventing
223
+ interruption by *other API calls*, this server's included, until `stop_widgets` or a game launch.
224
+ Pixelcade's Batocera screensaver hooks do the same: the screensaver starts attract mode with
225
+ `?nointerrupt` and ends it with `/attract/stop`. So while the screensaver runs, marquee calls succeed
226
+ but change nothing (PX-ATTRACT-LOCK); `stop_widgets` releases the lock.
227
+
228
+ **Pixelcade 8.x.** Pixelweb 8 is mostly about Sidekick companion displays; the API this server uses is
229
+ unchanged. It adds `/defaultmarquee` (8.0.14, PX-DEFAULT-MARQUEE), and a companion-app setting to show
230
+ static PNG marquees only, which may override `show_game_marquee(animation="animated")`. `doctor` shows
231
+ the pixelweb version and notes when it's too old for `show_default_marquee`.
232
+
233
+ **Names:** `system` is the Batocera ROM folder name (`mame`, `fbneo`, `snes`, …) and `rom` is the file
234
+ name without its extension (`pacman`, `Super Mario World (USA)`), the same values Batocera's own hook
235
+ scripts send.
236
+
237
+ Every tool that changes the display returns the same structured result as the sibling servers:
238
+
239
+ ```json
240
+ { "pixelcade": "Arcade marquee", "outcome": "done", "detail": "Showing the text",
241
+ "sent": ["GET /text?t=GAME%20NIGHT&c=cyan"], "warnings": [] }
242
+ ```
243
+
244
+ `outcome` is `done` or `dry_run`; `sent` is the exact GET (or, in a dry run, the one it would send).
245
+
246
+ ## Safety design
247
+
248
+ - **Namespace isolation.** Every tool is prefixed with `pixelcade_` to eliminate collision with other MCP servers in multi-server configurations.
249
+ - **Hardware Rate Limiting.** Commands are regulated via TokenBucket rate limiters:
250
+ - USB serial queue protection (burst 6, refill 2/s) prevents buffer overruns on the V2 board.
251
+ - High-draw Pulse LED protection (burst 3, refill 0.5/s) prevents thermal and current overload.
252
+ - **Network topology & PII masking.** Network URLs and IP addresses are masked (e.g. `http://x.x.x.50:8080`) in tool outputs, logs, and error messages via `mcp_shared.logsafe`.
253
+ - **A fixed set of endpoints.** Paths are built in `server.py` from constants plus validated arguments.
254
+ Pixelweb also serves `/quit`, `/reboot`, `/shutdown`, `/arcade/write` (writes the marquee's SD card),
255
+ `/upload`, `/settings`, `/update/*` and `/ledstrip/numpixels`; none are reachable through this server.
256
+ - **Arguments can't become other URLs.** Each model-supplied path value must be a single segment (no
257
+ `/`, `\`, `.`/`..` or control characters) and is then percent-encoded, `/` included. Zones, colors,
258
+ tickers and team codes are validated; effect names must match a strict pattern.
259
+ - **A Pulse brightness cap**, 128 by default. Each WS2812B LED can draw 60 mA at full white, and Pixelcade
260
+ warns that powering more than about 40 LEDs from the V2 board without an external 5V supply (with a
261
+ common ground) can overload it. Raise `PIXELCADE_PULSE_MAX_BRIGHTNESS` once your strips have their own
262
+ supply. The firmware's own thermal throttling (55/60/65 °C) is a backstop, not a plan.
263
+ - **Pixelweb has no authentication**, so anything on your LAN can already call every endpoint. Keep port
264
+ 8080 off the internet. Over HTTP, this server is the gate: Access assertions are checked on every
265
+ request, and without Access it serves only its own machine ([Service and Cloudflare](#service-and-cloudflare)).
266
+ - Tools carry titles and MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`,
267
+ `openWorldHint`). Nothing is marked destructive: every display change is undone by the next one.
268
+ - Structured XML docstrings (`<use_case>` and `<important_notes>`) guide models on tool preconditions, display lifetimes, and routing.
269
+ - Errors are actionable ("pixelweb isn't answering at …", "Can't resolve batocera.local; set
270
+ PIXELCADE_URL to the IP"), and logs go to stderr because stdout belongs to the MCP transport.
271
+
272
+ ## Verification status
273
+
274
+ Every pixelweb behavior the code relies on is listed in `src/pixelcade_mcp/assumptions.py`, with its
275
+ source and how sure we are; the code cites each id where it depends on it, and
276
+ [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-pixelcade/blob/main/HARDWARE_VALIDATION.md) has the step that confirms it. *high* means documented
277
+ and used the same way by Pixelcade's own scripts; *medium* means documented only; *low* means our own
278
+ guess. Hardware results so far are from pixelweb 8.0.17 on a V2 board (2026-10-22).
279
+
280
+ | Assumption | Confidence | Status |
281
+ |---|---|---|
282
+ | `PX-GET-API` | high | hardware-verified |
283
+ | `PX-INFO` | medium | hardware-verified |
284
+ | `PX-CURRENTGAME` | medium | hardware-verified |
285
+ | `PX-LISTS` | low | hardware-contradicted |
286
+ | `PX-IMAGES-LIST` | high | hardware-verified |
287
+ | `PX-REPLIES` | low | hardware-verified |
288
+ | `PX-TEXT` | medium | hardware-verified |
289
+ | `PX-QUEUE` | medium | hardware-verified |
290
+ | `PX-HOLD` | medium | hardware-verified |
291
+ | `PX-FRONTEND` | high | hardware-verified |
292
+ | `PX-WIDGETS` | medium | hardware-verified |
293
+ | `PX-ATTRACT-LOCK` | medium | hardware-verified |
294
+ | `PX-DEFAULT-MARQUEE` | low | hardware-verified |
295
+ | `PX-ZONES` | medium | simulator-only |
296
+ | `PX-BRIGHTNESS` | medium | simulator-only |
297
+ | `PX-POWER` | high | simulator-only |
298
+
299
+ ## Not yet included
300
+
301
+ - Pulse **batch zone control**, **MAX7219** 7-segment/dot-matrix displays, and **mini LCD** (`/ministats`)
302
+ endpoints, which Pixelcade documents but this server doesn't wrap yet.
303
+ - Editing the attract-mode playlist, and OAuth-backed widgets (Spotify, Plex, YouTube, Google Calendar).
304
+ - Uploading custom artwork (`/upload`): useful, but it writes files on the arcade, so it should be opt-in.
305
+
306
+ ## Development
307
+
308
+ ```sh
309
+ pip install -e ".[dev]"
310
+ pytest # validators, HTTP client, tools via an in-process MCP client, dry run, faults, CLI, stdio, HTTP + Access
311
+ ruff check . && ruff format --check .
312
+ mypy # strict
313
+ ```
314
+
315
+ Tests run against the wire-level fake pixelweb (`sim/fake_pixelweb.py`), which records each request's raw
316
+ path, so the exact percent-encoding on the wire is asserted. To poke at the tools interactively:
317
+ `npx @modelcontextprotocol/inspector mcp-server-pixelcade`.
318
+
319
+ ## License
320
+
321
+ MIT. See [LICENSE](https://github.com/SDNick484/mcp-server-pixelcade/blob/main/LICENSE).
@@ -0,0 +1,24 @@
1
+ mcp_server_pixelcade-0.1.0.dist-info/licenses/LICENSE,sha256=UaFe7iKAtdvNfrhH4lJ-v0InFSbjZGMZ4dLXeVf7o8g,1076
2
+ pixelcade_mcp/__init__.py,sha256=sM7Bai9XZ5pYv9JptQHjckyOIviVkdaN2LkOhmAU6Ng,318
3
+ pixelcade_mcp/assumptions.py,sha256=b5HuqWkxZUqOqyfgmO2Bramh8SLowvZpV7zs7n7hq2A,13001
4
+ pixelcade_mcp/cli.py,sha256=310N-OfdlzEXGj4JuP8S3Dw6D1QaIEq8Ri3a_s7u7TI,8886
5
+ pixelcade_mcp/client.py,sha256=tScf43_hi4tSPlAqEmc7zgEJ-KWC3zHyRRaWS13cGEU,15045
6
+ pixelcade_mcp/config.py,sha256=T3Mig3bMKXBkGs0XRmqTjC7inebRcVfbcx0alXnYYPc,10164
7
+ pixelcade_mcp/doctor.py,sha256=tDxvDr39gqF9w27fjoRiCkyUE2lp3YEX1aU41JW66aE,7755
8
+ pixelcade_mcp/effects.py,sha256=7XirNdfQwKgm9avoCfoJ33hEY0C_aKgAicEG1nZIBG0,4941
9
+ pixelcade_mcp/limits.py,sha256=Gf0t_qp2B31Ch_Lrm9Lnx_BPxfn5L8bZfhL-g8gm5qE,1445
10
+ pixelcade_mcp/logsafe.py,sha256=QuCitdXqckrLQSzb-V0R2pxFhLyqMhPSKQq7s3R97mI,15892
11
+ pixelcade_mcp/lookup.py,sha256=TmP8Abg5drIKTzHZ928YJ8Sa1qSDmvymEhwnSQpun6s,2235
12
+ pixelcade_mcp/ratelimit.py,sha256=aIhy8CAPMxJdEdlMvFCjUT8jElFKvb0QcrR8zxAB6Ps,9840
13
+ pixelcade_mcp/registry.py,sha256=9JIwD7hYao7m6PWJXZDAiGQfd8dfGWIfruuAJYNFsUw,7027
14
+ pixelcade_mcp/remote.py,sha256=v7d4C9Phz11hc9XsORMJ7VY-GO2GMrdfaa_gEU8b7s8,23958
15
+ pixelcade_mcp/server.py,sha256=__2GRoSNayYdcbBPytvK8tYMFCyVYZZ1CJuPEh3hiR0,38556
16
+ pixelcade_mcp/tasks.py,sha256=FttB8XI_evXjIJUyWz7rtjENQVhgTS6ZcbEB5v-crkc,7815
17
+ pixelcade_mcp/tools.py,sha256=TlMObSXFMZst3ssGBrSwtS3WpjbAV0ngKfU5mnajWNU,1363
18
+ pixelcade_mcp/sim/__init__.py,sha256=rtOXDNhwRl-DCbt4Lsf5qtLLJiqeNzty6tOrXZFP-vI,73
19
+ pixelcade_mcp/sim/fake_pixelweb.py,sha256=nBo0JKWUN-dURNnLuXpTMULupY2GBHFXF4h7Biv83zQ,11285
20
+ mcp_server_pixelcade-0.1.0.dist-info/METADATA,sha256=Mj2_pg3bY4LzjIA80X3MrYVsyg_6IH74vZwrzu59duI,19939
21
+ mcp_server_pixelcade-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
22
+ mcp_server_pixelcade-0.1.0.dist-info/entry_points.txt,sha256=JEzBzxrdGTjJqTtd7LPVuDAi6mXCYUNvU2Z02ANg8cQ,64
23
+ mcp_server_pixelcade-0.1.0.dist-info/top_level.txt,sha256=SOemg7JeEEDlB5WlfRr3YAYDmPk96CNPrLLKYPvGEZI,14
24
+ mcp_server_pixelcade-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-server-pixelcade = pixelcade_mcp.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicholas E. Andrade
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.
@@ -0,0 +1 @@
1
+ pixelcade_mcp
@@ -0,0 +1,5 @@
1
+ """MCP server for Pixelcade LED marquees (and Pixelcade Pulse lighting) via the pixelweb REST API."""
2
+
3
+ # The one place the version lives: pyproject.toml reads it from here. Bump it for every release, since pip
4
+ # keeps an installed copy whose version matches, even when the code under it changed.
5
+ __version__ = "0.1.0"
@@ -0,0 +1,230 @@
1
+ """Every pixelweb behavior this server relies on, and whether hardware has confirmed it.
2
+
3
+ The same scheme as the sibling servers: each claim has an id that the code cites
4
+ where it depends on it (``# ASSUMPTION PX-QUEUE``), that a test cites, that
5
+ HARDWARE_VALIDATION.md cites in the step confirming it, that the README's
6
+ verification table lists, and that ``mcp-server-pixelcade doctor`` prints while
7
+ it's unconfirmed. tests/test_assumptions.py fails if they drift apart.
8
+
9
+ Sources, read 2026-10: Pixelcade's API reference (pixelcade.org/developers), the
10
+ Pulse page (pixelcade.org/pulse), the release notes (pixelcade.org/release-notes,
11
+ through pixelweb 8.0.17), and Pixelcade's own Batocera hook scripts
12
+ (github.com/alinke/pixelcade-linux, batocera/scripts/). Nothing has been seen on
13
+ the owner's marquee yet. To record a result, set ``status`` to
14
+ "hardware-verified" (or "hardware-contradicted"), say what you saw in ``note``.
15
+
16
+ Confidence is about the *claim*, judged from its source:
17
+ high - documented, and used the same way by Pixelcade's own scripts
18
+ medium - documented, but not used by anything we can read
19
+ low - our own guess; the simulator implements it but nothing confirms it
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from .registry import Assumption, Confidence, Status, by_id
25
+ from .registry import contradicted as _contradicted
26
+ from .registry import unverified as _unverified
27
+
28
+ # Confidence and Status are re-exported (no_implicit_reexport): callers may need the literal types.
29
+ __all__ = ["ASSUMPTIONS", "BY_ID", "Assumption", "Confidence", "Status", "contradicted", "unverified"]
30
+
31
+ DOCS = "pixelcade.org/developers"
32
+ SCRIPTS = "pixelcade-linux batocera/scripts"
33
+ RELEASES = "pixelcade.org/release-notes"
34
+ SEEN = "Seen 2026-10-22 on a V2 LED (firmware PIXR0023), pixelweb 8.0.17 on Batocera 43, from a Proxmox LXC: "
35
+
36
+
37
+ ASSUMPTIONS: tuple[Assumption, ...] = (
38
+ Assumption(
39
+ "PX-GET-API",
40
+ "Every control is an HTTP GET on port 8080; options are query parameters, some of them bare flags "
41
+ "(?nogif, ?nointerrupt), and spaces in ROM names are sent as %20.",
42
+ f"{DOCS}; {SCRIPTS} (rawurlencode, curl)",
43
+ "high",
44
+ status="hardware-verified",
45
+ note=SEEN + "the marquee for 'Super Mario World 2 - Yoshi's Island (USA)' showed, sent as ...%20Yoshi%27s"
46
+ "%20Island%20%28USA%29; flags like ?nogif and scroll=false work. pixelweb matches artwork by exact name: "
47
+ "the ROM's '(USA) (Rev 1)' found nothing, so the default marquee showed.",
48
+ ),
49
+ Assumption(
50
+ "PX-INFO",
51
+ "/info returns JSON with type, resolution, v2board, firmwareVersion, softwareVersion, hasLEDStripSupport, "
52
+ "ledStrip, ledStripBrightness and ledStripNumPixels.",
53
+ f"{DOCS} (Device Information example)",
54
+ "medium",
55
+ status="hardware-verified",
56
+ note=SEEN + "every field present, plus many more: platform, arcadeFrontend 'Batocera', ledActive, "
57
+ "noAnimatedMarquees (the 8.x 'static PNG marquees only' setting, now in get_status), sidekick, "
58
+ "sidekick-ids, sidekick-models, sidekickOnly, centerMatrixResolution, isMiSTer, vestaboard*.",
59
+ ),
60
+ Assumption(
61
+ "PX-CURRENTGAME",
62
+ "/currentgame returns the last game or system marquee shown, by anyone (the frontend's hooks, the "
63
+ "screensaver, this server), as 'system%rom', or 'system%' after a system marquee; text and animations "
64
+ "don't change it. (The game-selected hook's comment says 'system,rom'.)",
65
+ f"{SCRIPTS} game-selected; {DOCS}; hardware",
66
+ "medium",
67
+ status="hardware-verified",
68
+ note=SEEN + "'neogeo%eightman' ('%', not a comma). It followed every marquee: scrolling gave "
69
+ "genesis%Old-Towers; show_text and play_animation left it; show_console_marquee snes made it 'snes%'; "
70
+ "show_game_marquee galaga made it mame%galaga. The claim used to say it was the frontend's selection; "
71
+ "it isn't, so the server remembers the frontend's itself (server._Marquees); in one session, Galaga "
72
+ "then show_current_game restored the frontend's gba/SpaceTwins. During the random-video "
73
+ "screensaver it changed with each video (mystwaru, eightman, circusc), so "
74
+ "the screensaver's game-selected events update it even while attract mode holds the marquee: after the "
75
+ "screensaver, it names the last video's game, not the user's selection.",
76
+ ),
77
+ Assumption(
78
+ "PX-LISTS",
79
+ "/arcade/list, /still/list and /animations/list return a list of names.",
80
+ DOCS,
81
+ "low",
82
+ status="hardware-contradicted",
83
+ note=SEEN + "all three answer 404 (so do the documented /list/image/<system>, /list/image/all and "
84
+ "/list/video, which may be LCD-only). list_artwork uses PX-IMAGES-LIST instead.",
85
+ ),
86
+ Assumption(
87
+ "PX-IMAGES-LIST",
88
+ 'POST /api/images/list with the JSON body {"name": "<folder>"} lists /userdata/system/pixelcade/<folder> '
89
+ "as [{name, system, urlPath, imagePath}], name being a file name; a missing folder answers 400 (plain text "
90
+ "'open <path>: no such file or directory'), and an empty name answers null.",
91
+ "pixelweb's own web page (its artwork browser's script); checked on hardware",
92
+ "high",
93
+ status="hardware-verified",
94
+ note=SEEN + "mame: 12,415 files (10,291 PNG, 2,124 GIF; 2 MB of JSON in 0.07 s); animations: 702 PNG; "
95
+ "console, user and system folders too; 'still' has no folder. The one POST this server sends: a read. "
96
+ "Undocumented, so a pixelweb update could change it.",
97
+ ),
98
+ Assumption(
99
+ "PX-REPLIES",
100
+ 'Controls answer 200 with an empty body (/defaultmarquee: {"status": "ok"}), even when the '
101
+ "artwork is missing; an endpoint this pixelweb doesn't have answers 404 '404 page not found'.",
102
+ "pixelweb is a Go net/http server; hardware",
103
+ "low",
104
+ status="hardware-verified",
105
+ note=SEEN + "every control call answered 200 with nothing in the body; /arcade/stream/mame/nosuchgame "
106
+ "answered 200 too, so a reply can't say whether artwork exists (list_artwork can). The claim used to "
107
+ "say 'a short body'.",
108
+ ),
109
+ Assumption(
110
+ "PX-TEXT",
111
+ "/text takes the message in t=, with line breaks as the literal two characters \\n, plus c=, s=, l=, "
112
+ "scroll=, lines=, blink= and left=.",
113
+ DOCS,
114
+ "medium",
115
+ status="hardware-verified",
116
+ note=SEEN + "color, two fixed lines from a literal \\n (sent %5Cn), blink on 'PLAYER 1', and four "
117
+ "left-justified lines all showed as documented. blink's 9-character limit is from the docs, not tried.",
118
+ ),
119
+ Assumption(
120
+ "PX-QUEUE",
121
+ "A call with l=N (N >= 1) joins a queue; a call without l interrupts and clears it. Queued calls from "
122
+ "scripts carry event=FEScroll.",
123
+ DOCS,
124
+ "medium",
125
+ status="hardware-verified",
126
+ note=SEEN + "a marquee queued with l=5&event=FEScroll played for about 5 s, then text queued with l=1 "
127
+ "scrolled once; blank_display (no l) interrupted.",
128
+ ),
129
+ Assumption(
130
+ "PX-HOLD",
131
+ "l=-1, documented as 'infinite until next command', shows text once and then the marquee goes blank; "
132
+ "l=99999 holds it until interrupted, and a plain call followed by the same call with l=99999 shows it "
133
+ "at once (the plain call interrupts and clears the queue) and holds it.",
134
+ f"{DOCS} (Looping & Queue System); hardware",
135
+ "medium",
136
+ status="hardware-verified",
137
+ note=SEEN + "text?t=HELLO&l=-1 showed once, then blank; HOLD ONE with l=99999 replaced Galaga and stayed; "
138
+ "the plain-then-l=99999 pair showed at once and stayed, and a long message sent the same way kept "
139
+ "scrolling for 30 s; scroll=false text stayed. Short text doesn't scroll (it fits). Held text stayed "
140
+ "until scrolling in the frontend replaced it. Not yet seen: the pair while the frontend's own l=99999 "
141
+ "game marquee holds the queue.",
142
+ ),
143
+ Assumption(
144
+ "PX-FRONTEND",
145
+ "EmulationStation's hooks call /console/stream/<system>/?event=FEScroll and "
146
+ "/arcade/stream/<system>/<rom>?event=FEScroll as you scroll (game-selected skips the call when the game "
147
+ "matches /currentgame), and /attract/stop then a queued marquee (l=99999&event=GameStart) on every game "
148
+ "launch, so whatever this server shows lasts until the next frontend event.",
149
+ f"{SCRIPTS} system-selected, game-selected, game-start",
150
+ "high",
151
+ status="hardware-verified",
152
+ note=SEEN + "scrolling in the frontend replaced held text; launching a game (through mcp-server-batocera) "
153
+ "ended a locked clock and showed the game's marquee. Hook scripts from pixelcade-linux (2026-06).",
154
+ ),
155
+ Assumption(
156
+ "PX-WIDGETS",
157
+ "/clock (clockType=, 12h=), /weather (location=), /stocks (tickers=), /sports[/<league>] (teams=) and "
158
+ "/attract show widgets; ?nointerrupt locks one in place (PX-ATTRACT-LOCK); /attract/stop ends them.",
159
+ DOCS,
160
+ "medium",
161
+ status="hardware-verified",
162
+ note=SEEN + "Galaga and Pac-Man clocks (12h=false too), weather (saved location and a ZIP), a stock ticker "
163
+ "and NFL scores all rendered, with no settings beyond the install. ?nointerrupt and /attract: step 5.",
164
+ ),
165
+ Assumption(
166
+ "PX-ATTRACT-LOCK",
167
+ "?nointerrupt locks the marquee: other API calls, this server's included, don't change it until "
168
+ "/attract/stop. Pixelcade's Batocera screensaver-start hook calls /attract?nointerrupt and "
169
+ "screensaver-stop calls /attract/stop, so while the cabinet's screensaver runs, the marquee shows the "
170
+ "attract playlist and ignores both the screensaver's game-selected events and this server.",
171
+ f"{DOCS} (attract mode: 'prevents interruption by other API calls'); {SCRIPTS} screensaver-start, "
172
+ "screensaver-stop",
173
+ "medium",
174
+ status="hardware-verified",
175
+ note=SEEN + "a clock shown with lock=true stayed through frontend scrolling, and show_text over it "
176
+ "answered 200 but didn't show. During the random-video screensaver the marquee stayed in attract mode, "
177
+ "didn't follow the videos and ignored show_text; after stop_widgets it followed the videos. Unknown: "
178
+ "whether queued (l=N) calls wait for the lock, and whether Pulse calls are locked too.",
179
+ ),
180
+ Assumption(
181
+ "PX-DEFAULT-MARQUEE",
182
+ "pixelweb 8.0.14 and later have GET /defaultmarquee, which shows the default marquee "
183
+ "(console/default-marquee.png) on the LED and LCD when called with no parameters; older versions answer 404.",
184
+ f"{RELEASES} (8.0.14); {DOCS} (default marquee path)",
185
+ "low",
186
+ status="hardware-verified",
187
+ note=SEEN + 'GET /defaultmarquee answered 200 {"status": "ok"} and showed the Batocera marquee (this '
188
+ "install's default). Only in the release notes, not the API reference; how it interacts with the queue "
189
+ "and the attract lock isn't known.",
190
+ ),
191
+ Assumption(
192
+ "PX-ZONES",
193
+ "Pulse zones are named (left_strip, right_strip, ...) or numbered 0-35; effects are "
194
+ "/ledstrip/zone/<z>/effect/<name>, fills /ledstrip/zone/<z>/flood, but clearing one zone is "
195
+ "/ledstrip/<z>/clear (no zone/ segment).",
196
+ f"{DOCS} (Pulse API)",
197
+ "medium",
198
+ note="The clear path's missing zone/ segment may be a documentation typo; check it first.",
199
+ ),
200
+ Assumption(
201
+ "PX-BRIGHTNESS",
202
+ "/ledstrip/brightness/<0-255> sets the strips' brightness and /ledstrip/getbrightness reads it back "
203
+ "from the board.",
204
+ f"{DOCS} (Pulse API)",
205
+ "medium",
206
+ note="Documented; needs strips connected to check. Seen 2026-10-22 with strips configured in pixelweb "
207
+ "(ledStrip true, 50 LEDs) but none connected: /ledstrip/getbrightness never answered (30 s), so get_status "
208
+ "and doctor wait only PULSE_STATUS_TIMEOUT_S (2 s) and report pulse.responding false.",
209
+ ),
210
+ Assumption(
211
+ "PX-POWER",
212
+ "Each WS2812B LED draws up to 60 mA at full white, and the V2 board can power only about 40 LEDs "
213
+ "itself; more needs an external 5 V supply with a common ground. Hence the default brightness cap of 128.",
214
+ "pixelcade.org/pulse (Powering and Safety)",
215
+ "high",
216
+ note="Pixelcade's own guidance. The cap is a safety margin, not a measured limit.",
217
+ ),
218
+ )
219
+
220
+ BY_ID = by_id(ASSUMPTIONS)
221
+
222
+
223
+ def unverified() -> list[Assumption]:
224
+ """Not checked on hardware yet: the code relies on them on the strength of docs or source alone."""
225
+ return _unverified(ASSUMPTIONS)
226
+
227
+
228
+ def contradicted() -> list[Assumption]:
229
+ """Checked on hardware and found wrong: the code no longer relies on them (see each ``note``)."""
230
+ return _contradicted(ASSUMPTIONS)