TRMNL Chess MCP server
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@TRMNL Chess MCP serverknight to f3"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
TRMNL Chess MCP server
Play chess by voice against a Pebble Index ring, with the board rendered on a TRMNL e-ink display. Pebble's cloud agent is the MCP client; this server validates every move, runs the chess engine, and pushes the resulting board image to TRMNL. Pebble never sees the board — this server does all the validation and rendering coordination.
Ring -> Pebble's cloud agent -> this server (Streamable HTTP + bearer token)
|
v
lc0 (Maia weights) or Stockfish
|
v
board image URL -> TRMNL webhookFive tools: make_move, board_state, new_game, set_level, and
set_engine to switch between Maia and Stockfish mid-game.
Why this is slow on purpose
TRMNL's webhook accepts a push once every 5 minutes. The obvious read is "rate limit, route around it." The better read: the latency is the piece.
A chess board that updates instantly is a chess app, and there are already several better ones on the phone in your hand. A board that takes minutes to answer is an object in a room — something you walk past, notice has changed, think about, and speak a move at on your way to do something else. Correspondence chess worked this way by post for a century, and people found it a richer game precisely because the thinking happened between moves, not during them.
E-ink reinforces it physically. No glow, no notification, no way to demand attention — it just looks like a print sitting there until it doesn't. The ring matches this: capture is instant and effortless, but there's no screen and no reply, so you say the move and walk away.
So the honest framing is that the constraint arrived for infrastructure
reasons (TRMNL's own rate limit) and turned out to be the design. make_move
refusing a second move until the first one is actually visible (see
Setting up the TRMNL private plugin)
isn't a workaround for that limit — it's what makes the five minutes real
instead of cosmetic. The five-minute window is the metronome: a game paced
to the rhythm of a household rather than a session, played across a day by
someone passing through a room.
Related MCP server: broca-machina
Engines
Both ship in the container, switchable by voice without resetting the game:
Maia (default) — lc0 running Leela weights trained on human games at a specific rating (1100-1900). Runs at
nodes=1(no tree search), so it blunders like a human of that rating rather than playing like a weakened engine. This is what makes it fun to actually beat.Stockfish — the classic engine, skill levels 0-20 via its own
Skill LevelUCI option.
Say "set engine to maia" or "set engine to stockfish" any time; the difficulty resets to that engine's own default (a Maia rating and a Stockfish skill number aren't the same scale). Say "set level to 1600" or "set level to 8" to tune within whichever engine is active, or "make it harder" / "make it easier" for a relative nudge.
Environment variables
Variable | Required | Description |
| yes | Static token Pebble sends as |
| yes | UUID of your TRMNL private plugin (Webhook strategy). Treat this like a secret — anyone with it can push arbitrary content to your display. |
| no |
|
| no | Starting difficulty for |
| no | Where |
| no | Set by the Dockerfile; only override for a non-container local run. |
| no | Set automatically by Railway/most hosts. Defaults to |
Running locally
The real dependency here is the engines, not Python — chess_mcp_server.py
needs a working lc0 binary + Maia weights, or a stockfish binary, on
PATH or at the configured *_PATH. Building lc0 from source by hand is a
whole project on its own (see the Dockerfile), so unless you already have
these installed, running via Docker is the realistic path even for local
testing:
docker build -t chess-mcp .
docker run --rm -p 8000:8000 \
-e MCP_BEARER_TOKEN=$(openssl rand -hex 32) \
-e TRMNL_PLUGIN_UUID=your-uuid \
-v "$(pwd)/data:/data" -e STATE_FILE=/data/game.json \
chess-mcpIf you do have Stockfish installed locally (brew install stockfish) and
just want to test the move parser/game logic without Maia:
python3 -m venv .venv && source .venv/bin/activate # needs Python 3.10+
pip install -r requirements.txt
export MCP_BEARER_TOKEN=$(openssl rand -hex 32)
export TRMNL_PLUGIN_UUID=your-uuid
export ENGINE_KIND=stockfish
export STOCKFISH_PATH=/opt/homebrew/bin/stockfish
python chess_mcp_server.pyEndpoint at /mcp, health check at /healthz (no auth required).
Deploying to Railway
railway login # browser OAuth
railway init --name chess-mcp # first time only
railway add --service chess-mcp # creates the empty service
railway variable set "MCP_BEARER_TOKEN=$(openssl rand -hex 32)" --service chess-mcp --skip-deploys
railway variable set "TRMNL_PLUGIN_UUID=your-uuid" --service chess-mcp --skip-deploys
railway variable set "ENGINE_LEVEL=1500" --service chess-mcp --skip-deploys
railway variable set "STATE_FILE=/data/game.json" --service chess-mcp
railway up -c -y --service chess-mcp # builds the Dockerfile
railway domain --service chess-mcp # public HTTPS URL, real certVolume: game.json must survive redeploys, so mount a volume at /data
matching STATE_FILE above. The railway volume add CLI command is
currently broken (panics with a Rust unwrap() on None, reproduced on
CLI v5.44.1 and v5.45.7, both --json and interactive, with and without
--environment) — add it from the dashboard instead: open the service,
go to the Volumes tab, Add Volume, mount path /data. Railway
redeploys automatically once it's attached.
Redeploying later is just:
railway up -c -y --service chess-mcpPython-only changes rebuild fast — Docker's layer cache skips recompiling
lc0 (the slow part) as long as the Dockerfile itself didn't change.
railway logs --service chess-mcp tails live logs.
Configuring the Pebble app
Name: alphanumeric + hyphens only, no spaces. A space in this field is a confirmed Pebble bug — the agent silently never calls the tool (
ListToolsRequestsucceeds,CallToolRequestnever happens).ChessMCPorchess-mcpboth work.URL:
https://<your-railway-domain>/mcpTransport: Streamable (the dropdown is "SSE/Streamable" — pick Streamable, not SSE)
Authorization:
Bearer <your MCP_BEARER_TOKEN>— full string, including theBearerprefix
Custom MCP tools only run in Pebble's double-click recording mode; single-click stays on Pebble's built-in offline agent. Assign this server to whichever sandbox group your double-click uses.
Setting up the TRMNL private plugin
TRMNL dashboard -> Plugins -> search "Private Plugin" -> Add New
Name it, set Strategy to Webhook, save
On the plugin's settings page, click Edit Markup, paste in
trmnl_markup.liquidfrom this repoFind the Webhook URL field (
https://usetrmnl.com/api/custom_plugins/<uuid>) — the<uuid>is yourTRMNL_PLUGIN_UUID
The markup shows the board, whose move it is, move history, an
{{ engine }} · {{ level }} badge (e.g. "Maia · 1500") so you can see at a
glance what you're playing against without asking, and a captured-pieces
column on each side (Unicode chess glyphs, e.g. "♟♟♟♞") with the material
lead shown only next to whichever side is ahead — confirmed live on real
TRMNL hardware; the glyphs do render. Computed by replaying the full move
history rather than diffing piece counts, specifically so pawn promotions
never get miscounted as a capture (_captured_pieces in
chess_mcp_server.py).
TRMNL rate-limits webhook pushes to once per 5 minutes and 429s above that.
Inside that window, the server schedules a single deferred push for when the
window clears (always sending the latest position at that point, not a
backlog of every intermediate move) rather than dropping the update outright
— and refuses new moves via make_move until that deferred push actually
lands, so you can never get ahead of what the display is showing. In
practice this means moves faster than ~5 minutes apart get throttled to the
display's own refresh rate; anything slower never notices the limit at all.
Example phrases to try
Double-click the ring, then:
"New game." Starts fresh at the current engine/level.
"Pawn to e4." / "knight to f3" / "e4" — loose phrasing is fine, the server resolves it against the actual legal move list.
"Castle kingside."
"Take the bishop."
"Set engine to stockfish." then "set level to 8."
"Set engine to maia." then "set level to 1700."
"Make it harder." / "make it easier." — relative nudge, no number needed.
"What's the position?" — reads the board state back without a move.
"New game as black." — you'll play black; the engine (white) moves first before the position is pushed, and the board image is oriented with your pieces at the bottom.
"Rook d takes f8" / "Rdf8" — when two of the same piece could both reach a square, say which file (or rank) yours is on; either loose or compact phrasing works and resolves unambiguously instead of asking which one you meant.
"New game." with no color — random each time.
What's verified vs. assumed
Disambiguated moves ("two rooks, same rank") were structurally broken before
_try_compact_movewas added. The original loose-phrase filter could only recognize a full second square as an origin hint (like "e2 e4"), never a bare file or rank used purely to disambiguate ("the d-file rook"). It also delegated exact-notation attempts straight tochess.Board.parse_san, which is case-sensitive and rejects a stray uppercase letter outright -- so "RDf8", "RDF8", and even correctly-cased loose phrasing like "rook in the d-file to f8" all fell through to the broken fallback and either dumped the entire legal move list as "did you mean" or silently failed to narrow beyond both rooks._try_compact_movematches a cleaned, glued token directly against the legal move list with real file/rank disambiguation, case-insensitively, and is authoritative when it matches syntactically (raises its own properly-filtered ambiguous/no-match error rather than falling through). Verified against 18 cases replayed from real production logs and the exact screenshots that surfaced the bug (seeparser_extract.py-style local testing --python-chessinstalls fine outside the container for this, no Docker needed) before deploying.No Debian/Ubuntu package for lc0 exists (checked packages.debian.org directly) — it's compiled from source in the Dockerfile, pinned to release
v0.32.1. The build-time smoke test (smoke_test.py) actually runs lc0 against the Maia 1500 net and asserts a legal move comes back, so a broken engine fails the image build, not the first voice command.Maia weight files are fetched from
raw.githubusercontent.com/CSSLab/maia-chess/master/maia_weights/and verified withfileto actually be gzip before the build proceeds — GitHub's web UI is known to sometimes serve.pb.gzalready decompressed, which breaks the filename lc0 expects.Stockfish comes from Debian's own
stockfishpackage (/usr/games/stockfish), no build step needed.The original
chess_mcp_server.pyreferenced aPIECESdict for parsing spoken piece names ("knight", "night" -> knight) that was never defined — fixed, since it would have thrownNameErroron most non-exact-notation moves.The server's
/healthzroute was exempted from the bearer-auth middleware but never actually registered as a route, so it 404'd instead of returning 200 — fixed by registering it explicitly.Webhook was picked over TRMNL's Polling strategy for this plugin. TRMNL's own docs list polling's refresh options as 15/60/360/720/1440 minutes — 15 minutes is the fastest polling can go, three times slower than webhook's 5-minute floor, for no offsetting benefit (our updates are already event-driven by a spoken move, not something a fixed poll schedule helps with).
A webhook 200 OK is not proof the physical screen updated. TRMNL's architecture is device-pull, not server-push, at the hardware level — per their docs, "devices ping the server, not the other way around." Accepting a webhook just queues the data at TRMNL; the e-ink display only redraws on its own separate refresh cadence, and there's no API callback for "the device actually displayed this."
make_move's "board not yet updated" guard is therefore a proxy for TRMNL has accepted the latest position, not a hard guarantee the screen has physically changed by the time it lets the next move through — the best signal available, just not an ironclad one.
Troubleshooting
Pebble says "action completed" 2-3 times for one thing I said. Pebble's
cloud agent was chaining multiple tool calls within a single turn whenever a
call returned an error — e.g. make_move("Pawn d6") came back ambiguous
("did you mean Bd6, d6?"), and instead of relaying that question to the
user, the agent silently picked one and called make_move again itself
(confirmed in railway logs). Every tool's docstring now explicitly
instructs the agent to speak an error back verbatim and stop, not retry
with a guessed correction — but this is a prompt instruction to Pebble's
own LLM, not something this server can force, so if it recurs the fix is
tightening those docstrings further, not a server-side gate.
The engine played what looks like two moves in a row. Caught live:
within one Pebble session (one double-click), the agent called
make_move twice unprompted — played the user's move, then also played
an "obviously correct" recapture on their behalf after its own reply took
their queen. Both calls succeeded, so the error-retry docstring instruction
above never applied — this was a distinct failure mode. Fixed with an
actual server-side gate: make_move records, per ctx.session_id, when
that session last landed a move, and refuses another call from it inside
REPEAT_MOVE_WINDOW (20s). This one is enforced server-side, not just
requested in a docstring.
The window matters. The ring doesn't speak MCP — it hands a transcript to a long-lived Claude Code session on the Pi, and that agent is the client. Its transport session to this server survives many separate ring interactions across a whole day, so the original "one move per session, ever" gate bricked every genuine move after the first. A hallucinated follow-up lands a second or two after the first call returns; a real turn needs tens of seconds (hear the reply, double-click, speak, transcribe), so time separates them cleanly. The stamp is also written only once a move actually reaches the board — a rejection that changed nothing (ambiguous or unparseable phrasing, game over, display not yet refreshed) leaves the player free to clarify and move in the same session, which the original gate wrongly refused.
Pebble says "invalid tool call, action failed." Check railway logs --service chess-mcp — every tool call's arguments are visible there, and
engine/state errors are caught and returned as text rather than crashing.
The most common cause (seen on a sibling project): a space or special
character in the MCP server's Name field in the Pebble app.
railway volume add crashes with a Rust panic. Known broken CLI
command as of v5.44.1/v5.45.7 — add the volume from the Railway dashboard
instead (Volumes tab -> Add Volume -> mount path /data).
Pebble says "hold on, the board hasn't updated yet." You're inside
TRMNL's 5-minute rate-limit window from a prior move. This is intentional —
make_move refuses new moves until the deferred push is accepted by TRMNL,
so you're never several moves ahead of what's been sent to the display.
Check railway logs for push: deferred push sent to confirm it landed, or
just wait out the number of seconds the message gave you. Note this confirms
TRMNL accepted the update, not that the physical screen has redrawn yet —
see What's verified vs. assumed for why that
distinction exists.
A level/engine word wasn't understood. Both set_level and set_engine
return a descriptive error string (visible wherever Pebble surfaces tool
results) instead of crashing, and set_level's error lists every word it
does understand for the currently active engine.
Verifying the bearer check
curl -i https://<your-railway-domain>/healthz # should be 200, no auth needed
curl -i https://<your-railway-domain>/mcp # should be 401
curl -i https://<your-railway-domain>/mcp \
-H "Authorization: Bearer <your MCP_BEARER_TOKEN>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Pedagogical chess intelligence for AI agents: explain positions and games for a target Elo.
Chess MCP for Claude: engine analysis, attack maps, game review. One URL, no install.
Turns a phone into a camera+Bluetooth remote so AI assistants can see and control any PC.
Stockfish chess eval: best move, score, principal variation, multipv. $0.005/call via x402.
Related MCP Servers
- AlicenseBqualityDmaintenanceEnables AI assistants to control Anki Vector robots locally via natural language, providing tools for speech, motion, perception, and interaction without cloud dependency.2420MIT
- AlicenseNot gradedqualityAmaintenanceGives any text-based AI a voice and ears inside a Discord voice channel by transcribing speech, relaying to an LLM/agent, and speaking replies back.MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to control an STM32 microcontroller over serial by reading/writing GPIO, setting LED brightness, reading ADC channels, and displaying text on an OLED screen via natural language.1MIT
- FlicenseNot gradedqualityBmaintenanceEnables voice-driven event creation on a Skylight family calendar via a Pebble Index ring, including family member tagging, all-day and multi-day events, and natural language scheduling.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/SarjuThakkar/trmnl-chess-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server