mcp-server-pixelcade 0.1.0__tar.gz

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 (43) hide show
  1. mcp_server_pixelcade-0.1.0/LICENSE +21 -0
  2. mcp_server_pixelcade-0.1.0/PKG-INFO +321 -0
  3. mcp_server_pixelcade-0.1.0/README.md +287 -0
  4. mcp_server_pixelcade-0.1.0/pyproject.toml +64 -0
  5. mcp_server_pixelcade-0.1.0/setup.cfg +4 -0
  6. mcp_server_pixelcade-0.1.0/src/mcp_server_pixelcade.egg-info/PKG-INFO +321 -0
  7. mcp_server_pixelcade-0.1.0/src/mcp_server_pixelcade.egg-info/SOURCES.txt +41 -0
  8. mcp_server_pixelcade-0.1.0/src/mcp_server_pixelcade.egg-info/dependency_links.txt +1 -0
  9. mcp_server_pixelcade-0.1.0/src/mcp_server_pixelcade.egg-info/entry_points.txt +2 -0
  10. mcp_server_pixelcade-0.1.0/src/mcp_server_pixelcade.egg-info/requires.txt +11 -0
  11. mcp_server_pixelcade-0.1.0/src/mcp_server_pixelcade.egg-info/top_level.txt +1 -0
  12. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/__init__.py +5 -0
  13. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/assumptions.py +230 -0
  14. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/cli.py +192 -0
  15. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/client.py +354 -0
  16. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/config.py +321 -0
  17. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/doctor.py +180 -0
  18. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/effects.py +178 -0
  19. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/limits.py +30 -0
  20. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/logsafe.py +309 -0
  21. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/lookup.py +60 -0
  22. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/ratelimit.py +226 -0
  23. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/registry.py +141 -0
  24. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/remote.py +516 -0
  25. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/server.py +950 -0
  26. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/sim/__init__.py +1 -0
  27. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/sim/fake_pixelweb.py +252 -0
  28. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/tasks.py +174 -0
  29. mcp_server_pixelcade-0.1.0/src/pixelcade_mcp/tools.py +29 -0
  30. mcp_server_pixelcade-0.1.0/tests/test_assumptions.py +85 -0
  31. mcp_server_pixelcade-0.1.0/tests/test_cli.py +191 -0
  32. mcp_server_pixelcade-0.1.0/tests/test_client.py +202 -0
  33. mcp_server_pixelcade-0.1.0/tests/test_config.py +110 -0
  34. mcp_server_pixelcade-0.1.0/tests/test_http_tools.py +124 -0
  35. mcp_server_pixelcade-0.1.0/tests/test_logsafe.py +521 -0
  36. mcp_server_pixelcade-0.1.0/tests/test_lookup.py +41 -0
  37. mcp_server_pixelcade-0.1.0/tests/test_ratelimit.py +382 -0
  38. mcp_server_pixelcade-0.1.0/tests/test_registry.py +175 -0
  39. mcp_server_pixelcade-0.1.0/tests/test_remote.py +571 -0
  40. mcp_server_pixelcade-0.1.0/tests/test_stdio.py +30 -0
  41. mcp_server_pixelcade-0.1.0/tests/test_tasks.py +425 -0
  42. mcp_server_pixelcade-0.1.0/tests/test_tool_annotations.py +18 -0
  43. mcp_server_pixelcade-0.1.0/tests/test_tools.py +592 -0
@@ -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,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,287 @@
1
+ # mcp-server-pixelcade
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for **[Pixelcade](https://pixelcade.org) LED marquees**
4
+ and **Pixelcade Pulse** lighting (WS2812B strips, speaker rings and matrices on a V2 board). It lets an
5
+ MCP client such as Claude show game and system marquees, scroll text, run arcade-themed clocks and other
6
+ widgets, and drive Pulse lighting effects.
7
+
8
+ > **Status: early, tested on hardware except Pulse.** On a real V2 board (pixelweb 8.0.17, Batocera 43)
9
+ > every marquee, text, widget and frontend tool has been checked, plus dry run; Pulse waits for strips. See
10
+ > [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-pixelcade/blob/main/HARDWARE_VALIDATION.md) and the [verification table](#verification-status).
11
+
12
+ This is not an official Pixelcade project.
13
+
14
+ ## How it works
15
+
16
+ The Pixelcade installer runs **pixelweb**, a small HTTP listener, on the arcade host (on Batocera:
17
+ `/userdata/system/pixelcade/pixelweb`, port 8080, started from `/userdata/system/custom.sh`). Pixelweb
18
+ drives the marquee over USB and exposes a [REST API](https://pixelcade.org/developers). This server is a
19
+ typed, allow-listed client for that API, so it can run anywhere that can reach the arcade on TCP 8080:
20
+ your desktop, a laptop, or a homelab VM. Clients reach it over stdio, or over Streamable HTTP as a
21
+ service behind Cloudflare Access ([Service and Cloudflare](#service-and-cloudflare)).
22
+
23
+ ```
24
+ MCP client ──stdio──▶ mcp-server-pixelcade ──HTTP :8080──▶ pixelweb (Batocera) ──USB──▶ V2 board ─▶ LED panels
25
+ └──▶ Pulse strips
26
+ ```
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ python -m venv ~/.venvs/pixelcade && . ~/.venvs/pixelcade/bin/activate
32
+ pip install mcp-server-pixelcade
33
+ ```
34
+
35
+ From source instead (for the newest code, or to run the tests):
36
+
37
+ ```sh
38
+ git clone https://github.com/SDNick484/mcp-server-pixelcade.git && cd mcp-server-pixelcade
39
+ python -m venv .venv && . .venv/bin/activate
40
+ pip install -e ".[dev]" && pytest
41
+ ```
42
+
43
+ Requires Python 3.11+. Then check that pixelweb answers:
44
+
45
+ ```sh
46
+ PIXELCADE_URL=192.168.1.20 mcp-server-pixelcade doctor
47
+ ```
48
+
49
+ `doctor` checks each layer (TCP, `/info`, the frontend's current game, Pulse) and, when one fails, says
50
+ 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
51
+ on the marquee. It ends with the [assumptions](#verification-status) that hardware contradicted (listed; the code
52
+ no longer relies on them) and a count of those not yet confirmed; `doctor --verbose` lists those too, and
53
+ `doctor --json` always includes both lists.
54
+
55
+ ### Trying it without the arcade
56
+
57
+ ```sh
58
+ mcp-server-pixelcade simulate --write-config /tmp/pxsim # a fake pixelweb; prints every request it gets
59
+ export PIXELCADE_CONFIG_DIR=/tmp/pxsim # in another terminal
60
+ mcp-server-pixelcade doctor
61
+ mcp-server-pixelcade call show_text text="GAME NIGHT" color=cyan # one tool call, as the model makes it
62
+ mcp-server-pixelcade call --dry-run blank_display # reports the GET it would send
63
+ ```
64
+
65
+ ### On Alpine (e.g. a Proxmox LXC)
66
+
67
+ `deploy/alpine/` installs it on an always-on Alpine machine on the same LAN as the arcade, in the
68
+ sibling servers' `deploy/alpine/` layout so they can share an LXC: a venv in `/opt/mcp-server-pixelcade`,
69
+ a `mcp-pixelcade` system user, every setting in `/etc/mcp-server-pixelcade/env`, an OpenRC service
70
+ (`/etc/init.d/mcp-server-pixelcade`), and `mcp-server-pixelcade` on `PATH`, which runs as that user with
71
+ those settings, for `doctor` and `call` from a shell.
72
+
73
+ ```sh
74
+ git clone https://github.com/SDNick484/mcp-server-pixelcade.git && cd mcp-server-pixelcade
75
+ sh deploy/alpine/install.sh # as root; re-run to upgrade
76
+ vim /etc/mcp-server-pixelcade/env # PIXELCADE_URL=<the arcade's IP>
77
+ mcp-server-pixelcade doctor
78
+ mcp-server-pixelcade call show_text text="GAME NIGHT" color=cyan
79
+ rc-update add mcp-server-pixelcade default && rc-service mcp-server-pixelcade start
80
+ ```
81
+
82
+ A variable set in your shell beats the env file, so one-off overrides work as usual
83
+ (`PIXELCADE_DRY_RUN=1 mcp-server-pixelcade call ...`). mcp-server-batocera's
84
+ `deploy/alpine/setup-arcade.sh` installs both arcade servers in a Proxmox container in one go, and
85
+ enables and restarts both services. CI runs `deploy/alpine/smoke-test.sh` in a stock
86
+ `python:3.12-alpine` container: install, upgrade, the test suite on musl, `doctor` and `call` through the
87
+ command on `PATH` against the simulator, and the OpenRC service (as its user, an MCP call over HTTP,
88
+ Access settings from the env file).
89
+
90
+ ### Service and Cloudflare
91
+
92
+ The service runs `/opt/mcp-server-pixelcade/run.sh serve --http` under `supervise-daemon` (restarted if
93
+ it exits) as `mcp-pixelcade`, logging to `/var/log/mcp-server-pixelcade/server.log`. The HTTP transport
94
+ is `remote.py`, shared byte for byte with the sibling servers through
95
+ [mcp-server-shared](https://github.com/SDNick484/mcp-server-shared). Its settings are the `MCP_HTTP_*`,
96
+ `MCP_PUBLIC_HOSTS` and `CF_ACCESS_*` lines in `/etc/mcp-server-pixelcade/env`:
97
+
98
+ | Flag | Variable | Default |
99
+ | --- | --- | --- |
100
+ | `--bind` | `MCP_HTTP_BIND` | `127.0.0.1` |
101
+ | `--port` | `MCP_HTTP_PORT` | `8718` (the siblings use 8711 to 8716, Batocera 8717) |
102
+ | `--path` | `MCP_HTTP_PATH` | `/pixelcade/mcp` |
103
+ | `--public-host` | `MCP_PUBLIC_HOSTS` | none: only loopback Host headers pass |
104
+ | | `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` | none: serves only this machine |
105
+ | | `MCP_ALLOWED_EMAILS` | anyone Access lets in |
106
+
107
+ With Access configured, every request needs a valid Access assertion, checked here: signature (Access's
108
+ published keys), the application's audience tag, issuer, expiry, and the email allow-list if set; anything
109
+ else gets a plain 403. Without Access it serves only the machine it runs on: it won't listen on anything
110
+ but loopback, won't start with a `--public-host`, and answers 403 to anything a tunnel or proxy forwarded
111
+ (`--insecure-no-auth` turns that off for a short trusted-LAN test; don't run the service with it).
112
+
113
+ To use it as a connector, with cloudflared running on the same machine (keep the `127.0.0.1` bind):
114
+
115
+ 1. **Tunnel route:** a public hostname, say `mcp.example.com`, path `pixelcade/*`, to
116
+ `http://localhost:8718`. Several servers can share the hostname, one path each.
117
+ 2. **Access application** (self-hosted) covering that hostname and path, with a policy for who may use it
118
+ and Managed OAuth turned on, so Claude's connector can sign in through Access. Note its AUD tag.
119
+ 3. **The env file:** `MCP_PUBLIC_HOSTS=mcp.example.com`, `CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com`,
120
+ `CF_ACCESS_AUD=<the AUD tag>`, then `rc-service mcp-server-pixelcade restart`.
121
+ 4. **The connector:** `https://mcp.example.com/pixelcade/mcp`.
122
+
123
+ `GET /healthz` answers without auth, for monitoring. Details are in `src/pixelcade_mcp/remote.py`.
124
+
125
+ ## Use it with an MCP client
126
+
127
+ ```json
128
+ {
129
+ "mcpServers": {
130
+ "pixelcade": {
131
+ "command": "/path/to/mcp-server-pixelcade/.venv/bin/mcp-server-pixelcade",
132
+ "env": { "PIXELCADE_URL": "192.168.1.50" }
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ For Claude Code: `claude mcp add pixelcade -e PIXELCADE_URL=192.168.1.50 -- /path/to/.venv/bin/mcp-server-pixelcade`.
139
+
140
+ Running as a service behind Cloudflare Access instead, add `https://<your host>/pixelcade/mcp` as a
141
+ connector ([Service and Cloudflare](#service-and-cloudflare)).
142
+
143
+ ## Configuration
144
+
145
+ | Setting | Default | What it does |
146
+ | -------------------------------- | ------------------------------------- | -------------------------------------------------------------- |
147
+ | `PIXELCADE_URL` | `http://batocera.local:8080` | Where pixelweb listens. A bare host or IP gets `http://` and `:8080` |
148
+ | `PIXELCADE_PULSE_MAX_BRIGHTNESS` | `128` | Highest Pulse brightness/intensity the model may set (0-255) |
149
+ | `PIXELCADE_TIMEOUT` | `10` | Seconds to wait for pixelweb (1-60) |
150
+ | `PIXELCADE_CONFIG_DIR` | `$XDG_CONFIG_HOME/mcp-server-pixelcade` | Where an optional `config.json` lives |
151
+ | `PIXELCADE_NAME` | pixelweb's URL | How results name the marquee |
152
+ | `PIXELCADE_DRY_RUN` | off | Read pixelweb, change nothing (`serve --dry-run`) |
153
+ | `PIXELCADE_LOG_UNREDACTED` | off | Show LAN addresses in logs (`--no-redact`); secrets stay hidden |
154
+ | `PIXELCADE_DEBUG` | off | Debug logging (`--debug`) |
155
+
156
+ `config.json` can hold the same settings as `url`, `pulse_max_brightness`, `timeout_s`, `name` and `dry_run`; environment
157
+ variables win. `.local` names need mDNS, which usually doesn't cross VLANs or work in WSL2 without
158
+ mirrored networking. A DHCP reservation plus the IP address is the dependable choice.
159
+
160
+ ## Tools
161
+
162
+ 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`).
163
+
164
+ | Tool | What it does |
165
+ | -------------------------------- | ---------------------------------------------------------------------------------------------- |
166
+ | `pixelcade_get_status` | Reachability, hardware/firmware/software versions, the frontend's selected game, Pulse state |
167
+ | `pixelcade_list_artwork` | The marquees in one folder (a system, `console`, `animations`, `user`): names, still/animated |
168
+ | `pixelcade_list_pulse_effects` | Pulse effect names by category, per-game/per-system name patterns, and zone names |
169
+ | `pixelcade_show_game_marquee` | A game's marquee by system + ROM name; still/animated choice, optional queueing and fallback text |
170
+ | `pixelcade_show_console_marquee` | A system marquee, e.g. `snes` |
171
+ | `pixelcade_show_current_game` | Put back the marquee for the game selected in EmulationStation |
172
+ | `pixelcade_show_default_marquee` | Pixelcade's default marquee (needs pixelweb 8.0.14+) |
173
+ | `pixelcade_show_text` | Scrolling or fixed text: color, speed, loops, 1/2/4 lines, blink, left-justify |
174
+ | `pixelcade_play_animation` | One of Pixelcade's built-in animations |
175
+ | `pixelcade_show_widget` | Clock (19 arcade themes), weather, stocks, sports scores, or the attract-mode playlist |
176
+ | `pixelcade_stop_widgets` | Stop widgets/attract mode and return to arcade mode |
177
+ | `pixelcade_blank_display` | Black screen |
178
+ | `pixelcade_pulse_effect` | Run an effect on a Pulse zone (`rainbow`, `fire`, `mame_pacman`, …) with colors, speed, loops |
179
+ | `pixelcade_pulse_fill` | Solid color on a zone |
180
+ | `pixelcade_pulse_clear` | Turn off one zone, or all LEDs |
181
+ | `pixelcade_set_pulse_brightness` | Overall Pulse brightness, up to the server's cap |
182
+
183
+ **The frontend drives the marquee too.** EmulationStation's Pixelcade hooks call pixelweb whenever you
184
+ scroll to a system or game, and every game launch calls `/attract/stop`. So whatever the model shows
185
+ lasts until the next frontend event. `show_current_game` restores the frontend's marquee, and
186
+ `show_default_marquee` shows the default one.
187
+
188
+ **Locks.** `show_widget(lock=true)` sends `?nointerrupt`, which Pixelcade documents as preventing
189
+ interruption by *other API calls*, this server's included, until `stop_widgets` or a game launch.
190
+ Pixelcade's Batocera screensaver hooks do the same: the screensaver starts attract mode with
191
+ `?nointerrupt` and ends it with `/attract/stop`. So while the screensaver runs, marquee calls succeed
192
+ but change nothing (PX-ATTRACT-LOCK); `stop_widgets` releases the lock.
193
+
194
+ **Pixelcade 8.x.** Pixelweb 8 is mostly about Sidekick companion displays; the API this server uses is
195
+ unchanged. It adds `/defaultmarquee` (8.0.14, PX-DEFAULT-MARQUEE), and a companion-app setting to show
196
+ static PNG marquees only, which may override `show_game_marquee(animation="animated")`. `doctor` shows
197
+ the pixelweb version and notes when it's too old for `show_default_marquee`.
198
+
199
+ **Names:** `system` is the Batocera ROM folder name (`mame`, `fbneo`, `snes`, …) and `rom` is the file
200
+ name without its extension (`pacman`, `Super Mario World (USA)`), the same values Batocera's own hook
201
+ scripts send.
202
+
203
+ Every tool that changes the display returns the same structured result as the sibling servers:
204
+
205
+ ```json
206
+ { "pixelcade": "Arcade marquee", "outcome": "done", "detail": "Showing the text",
207
+ "sent": ["GET /text?t=GAME%20NIGHT&c=cyan"], "warnings": [] }
208
+ ```
209
+
210
+ `outcome` is `done` or `dry_run`; `sent` is the exact GET (or, in a dry run, the one it would send).
211
+
212
+ ## Safety design
213
+
214
+ - **Namespace isolation.** Every tool is prefixed with `pixelcade_` to eliminate collision with other MCP servers in multi-server configurations.
215
+ - **Hardware Rate Limiting.** Commands are regulated via TokenBucket rate limiters:
216
+ - USB serial queue protection (burst 6, refill 2/s) prevents buffer overruns on the V2 board.
217
+ - High-draw Pulse LED protection (burst 3, refill 0.5/s) prevents thermal and current overload.
218
+ - **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`.
219
+ - **A fixed set of endpoints.** Paths are built in `server.py` from constants plus validated arguments.
220
+ Pixelweb also serves `/quit`, `/reboot`, `/shutdown`, `/arcade/write` (writes the marquee's SD card),
221
+ `/upload`, `/settings`, `/update/*` and `/ledstrip/numpixels`; none are reachable through this server.
222
+ - **Arguments can't become other URLs.** Each model-supplied path value must be a single segment (no
223
+ `/`, `\`, `.`/`..` or control characters) and is then percent-encoded, `/` included. Zones, colors,
224
+ tickers and team codes are validated; effect names must match a strict pattern.
225
+ - **A Pulse brightness cap**, 128 by default. Each WS2812B LED can draw 60 mA at full white, and Pixelcade
226
+ warns that powering more than about 40 LEDs from the V2 board without an external 5V supply (with a
227
+ common ground) can overload it. Raise `PIXELCADE_PULSE_MAX_BRIGHTNESS` once your strips have their own
228
+ supply. The firmware's own thermal throttling (55/60/65 °C) is a backstop, not a plan.
229
+ - **Pixelweb has no authentication**, so anything on your LAN can already call every endpoint. Keep port
230
+ 8080 off the internet. Over HTTP, this server is the gate: Access assertions are checked on every
231
+ request, and without Access it serves only its own machine ([Service and Cloudflare](#service-and-cloudflare)).
232
+ - Tools carry titles and MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`,
233
+ `openWorldHint`). Nothing is marked destructive: every display change is undone by the next one.
234
+ - Structured XML docstrings (`<use_case>` and `<important_notes>`) guide models on tool preconditions, display lifetimes, and routing.
235
+ - Errors are actionable ("pixelweb isn't answering at …", "Can't resolve batocera.local; set
236
+ PIXELCADE_URL to the IP"), and logs go to stderr because stdout belongs to the MCP transport.
237
+
238
+ ## Verification status
239
+
240
+ Every pixelweb behavior the code relies on is listed in `src/pixelcade_mcp/assumptions.py`, with its
241
+ source and how sure we are; the code cites each id where it depends on it, and
242
+ [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-pixelcade/blob/main/HARDWARE_VALIDATION.md) has the step that confirms it. *high* means documented
243
+ and used the same way by Pixelcade's own scripts; *medium* means documented only; *low* means our own
244
+ guess. Hardware results so far are from pixelweb 8.0.17 on a V2 board (2026-10-22).
245
+
246
+ | Assumption | Confidence | Status |
247
+ |---|---|---|
248
+ | `PX-GET-API` | high | hardware-verified |
249
+ | `PX-INFO` | medium | hardware-verified |
250
+ | `PX-CURRENTGAME` | medium | hardware-verified |
251
+ | `PX-LISTS` | low | hardware-contradicted |
252
+ | `PX-IMAGES-LIST` | high | hardware-verified |
253
+ | `PX-REPLIES` | low | hardware-verified |
254
+ | `PX-TEXT` | medium | hardware-verified |
255
+ | `PX-QUEUE` | medium | hardware-verified |
256
+ | `PX-HOLD` | medium | hardware-verified |
257
+ | `PX-FRONTEND` | high | hardware-verified |
258
+ | `PX-WIDGETS` | medium | hardware-verified |
259
+ | `PX-ATTRACT-LOCK` | medium | hardware-verified |
260
+ | `PX-DEFAULT-MARQUEE` | low | hardware-verified |
261
+ | `PX-ZONES` | medium | simulator-only |
262
+ | `PX-BRIGHTNESS` | medium | simulator-only |
263
+ | `PX-POWER` | high | simulator-only |
264
+
265
+ ## Not yet included
266
+
267
+ - Pulse **batch zone control**, **MAX7219** 7-segment/dot-matrix displays, and **mini LCD** (`/ministats`)
268
+ endpoints, which Pixelcade documents but this server doesn't wrap yet.
269
+ - Editing the attract-mode playlist, and OAuth-backed widgets (Spotify, Plex, YouTube, Google Calendar).
270
+ - Uploading custom artwork (`/upload`): useful, but it writes files on the arcade, so it should be opt-in.
271
+
272
+ ## Development
273
+
274
+ ```sh
275
+ pip install -e ".[dev]"
276
+ pytest # validators, HTTP client, tools via an in-process MCP client, dry run, faults, CLI, stdio, HTTP + Access
277
+ ruff check . && ruff format --check .
278
+ mypy # strict
279
+ ```
280
+
281
+ Tests run against the wire-level fake pixelweb (`sim/fake_pixelweb.py`), which records each request's raw
282
+ path, so the exact percent-encoding on the wire is asserted. To poke at the tools interactively:
283
+ `npx @modelcontextprotocol/inspector mcp-server-pixelcade`.
284
+
285
+ ## License
286
+
287
+ MIT. See [LICENSE](https://github.com/SDNick484/mcp-server-pixelcade/blob/main/LICENSE).