Skip to main content
Glama
SarjuThakkar

TRMNL Chess MCP server

by SarjuThakkar

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 webhook

Five 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 Level UCI 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

MCP_BEARER_TOKEN

yes

Static token Pebble sends as Authorization: Bearer <token>. Generate with openssl rand -hex 32.

TRMNL_PLUGIN_UUID

yes

UUID of your TRMNL private plugin (Webhook strategy). Treat this like a secret — anyone with it can push arbitrary content to your display.

ENGINE_KIND

no

maia (default) or stockfish. Only sets the starting engine for a fresh game — set_engine switches it live.

ENGINE_LEVEL

no

Starting difficulty for ENGINE_KIND. Defaults to 1500 for Maia, 5 for Stockfish.

STATE_FILE

no

Where game.json lives. Point this at a mounted volume in production or state is lost on every redeploy.

LC0_PATH, MAIA_WEIGHTS_DIR, STOCKFISH_PATH

no

Set by the Dockerfile; only override for a non-container local run.

PORT

no

Set automatically by Railway/most hosts. Defaults to 8000 locally.

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-mcp

If 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.py

Endpoint 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 cert

Volume: 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-mcp

Python-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 (ListToolsRequest succeeds, CallToolRequest never happens). ChessMCP or chess-mcp both work.

  • URL: https://<your-railway-domain>/mcp

  • Transport: Streamable (the dropdown is "SSE/Streamable" — pick Streamable, not SSE)

  • Authorization: Bearer <your MCP_BEARER_TOKEN> — full string, including the Bearer prefix

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

  1. TRMNL dashboard -> Plugins -> search "Private Plugin" -> Add New

  2. Name it, set Strategy to Webhook, save

  3. On the plugin's settings page, click Edit Markup, paste in trmnl_markup.liquid from this repo

  4. Find the Webhook URL field (https://usetrmnl.com/api/custom_plugins/<uuid>) — the <uuid> is your TRMNL_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_move was 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 to chess.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_move matches 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 (see parser_extract.py-style local testing -- python-chess installs 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 with file to actually be gzip before the build proceeds — GitHub's web UI is known to sometimes serve .pb.gz already decompressed, which breaks the filename lc0 expects.

  • Stockfish comes from Debian's own stockfish package (/usr/games/stockfish), no build step needed.

  • The original chess_mcp_server.py referenced a PIECES dict for parsing spoken piece names ("knight", "night" -> knight) that was never defined — fixed, since it would have thrown NameError on most non-exact-notation moves.

  • The server's /healthz route 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"}}}'

Maintenance

ActivityMaintained
ResponsivenessNo issues

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

Related MCP Servers

  • A
    license
    B
    quality
    D
    maintenance
    Enables AI assistants to control Anki Vector robots locally via natural language, providing tools for speech, motion, perception, and interaction without cloud dependency.
    24
    20
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables 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.
    1
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables 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

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