foodchain-mcp
v1.1.7
Published
MCP server that lets an AI agent play Food Chain Magnate alongside humans.
Readme
foodchain-mcp — Food Chain Magnate agent bridge (MCP server)
A thin Model Context Protocol server that lets an AI agent (any MCP client) play Food Chain Magnate in a real multiplayer game, taking one seat alongside human players.
It is a bridge, not a bot: it holds one authenticated Socket.IO connection to the FCM server (exactly like the browser), mirrors the game state, and exposes tools to read state and submit moves. The FCM server stays the rules authority — illegal moves are rejected and the reason is surfaced back. All the reasoning lives in the MCP client (the agent).
Install
No clone or build needed — add one stdio entry to your MCP client (defaults target the public production deployment):
{
"mcpServers": {
"fcm": { "command": "npx", "args": ["-y", "foodchain-mcp"] }
}
}On the first join_game the bridge opens your system browser for Keycloak
login; the refresh token is cached locally so it won't ask again. To target a
different deployment, set the env vars below.
Compatibility: the bridge is released lockstep with the FCM server — version
X.Y.Ztargets serverX.Y.Z. Use the package version that matches your server deployment.
How a game works
The AI fills one seat in an otherwise-normal human game:
A human creates a room in the browser and shares the 6-character code — or the agent hosts it:
create_gamemakes the room and returns the code to share, andstart_gamelaunches it once everyone has joined and readied.In an MCP client configured with this server, you kick the agent off with a prompt (the server ships tools only — no bundled prompt). Example:
You're playing Food Chain Magnate. Call
join_gamefor room ABC123, then loopwait_for_turn→ (decide) →take_actionuntil the game ends. Only callget_statewhen you have a real decision andget_boardonly to place something; confirmations are justwait_for_turn→take_action. Play to win and confirm promptly so you don't stall the table.join_gameauto-readies the agent's seat. A human host clicks Start. The agent acts only on its own turns; its moves render on everyone's board, its reasoning shows in the MCP chat.
Tools
The tools are pull-based: responses stay lean by default and the agent fetches heavy detail (the board) only when a move needs it. This keeps the conversation transcript small over a long game, which keeps each turn fast.
| Tool | Purpose |
|------|---------|
| join_game({ roomCode, displayName?, chain?, username?, seatTag?, observer? }) | Authenticate, connect, join by code, auto-ready. Pass observer: true to spectate; otherwise falls back to an observer seat (reported explicitly) only if no player seat is free. |
| create_game({ displayName?, chain?, username?, seatTag? }) | Authenticate, connect, and create a new room, taking the (auto-readied) host seat. Returns the 6-character roomCode to share; watch the lobby roster via get_state. |
| start_game() | Host only: start the game created with create_game (needs ≥2 seated players, all ready). Returns the opening setup digest. |
| get_state({ full? }) | Lean seat-relative snapshot: phase, money, food, your hand + org chart, opponents (named hand, org chart, claimed milestones), available milestones, per-phase actionHints, recentLog, and boardRev. No board geometry/ASCII — call get_board for that. { full: true } embeds the board + ASCII too. |
| get_board({ ascii? }) | The structured board for placement: houses (number + cells + demand), gardens, restaurants, campaigns, and static drinkSources + size — exact [row,col]. Returns rev; re-call only when boardRev advances. { ascii: true } adds the visual overview. |
| get_raw_state() | Escape hatch: the full projected GameState JSON. |
| wait_for_turn({ timeoutMs? }) | Block until it's your turn, the game ends, or timeout (default 90s). Returns a status + the lean snapshot; re-call to keep waiting. Observer seats wake on each live state change (observed: true) instead. |
| take_action({ action, full? }) | Submit one move. action.type is the action name (recruit, placeRestaurant, confirmDinnertime, skipFood, …); other fields are the payload. Returns a curated delta of what changed (money, hand, milestones, board mutations, new log lines) + new phase + boardRev — not a full dump. On rejection: the error + actionHints. { full: true } returns the whole digest instead. |
| get_log({ limit? }) | Recent game-log lines. |
| get_rules({ section? }) | The FCM rulebook. Omit section for the full text, or pass a heading (e.g. Dinnertime, Recruit, Marketing, Milestones) for one section. |
| describe_actions({ type? }) | Exact parameter structure for take_action — every action's params (name, type, required, meaning, where to read the value from get_state) + an example. Self-contained; use it instead of guessing. |
How the agent learns the game
Two channels, both MCP-native — no kickoff prompt required:
- Server instructions (always-on): on connect the server hands the client a
compact primer — what FCM is, the pull-based
join_game → wait_for_turn → (decide) → take_actionloop, the phase flow, the action types per phase, and how/when to read the board. Most clients feed this to the model automatically. get_rulestool (on-demand): the full rulebook, or a single section.
wait_for_turn and get_state both carry actionHints (legal options/budgets
for the current phase), so for confirmations and simple skips the agent can act
straight from wait_for_turn without any extra read.
Authentication
The bridge acquires a Keycloak token via the first method that applies:
- Cached refresh token — silent; survives restarts (see cache below).
- Password grant — used when
FCM_USERNAME+FCM_PASSWORDare set. For headless/CI runs that can't open a browser. - Browser login (Authorization Code + PKCE) — the default when no password
is configured: on first
join_gamethe system browser opens for Keycloak login and the redirect is captured on a loopback port. No password is stored in the config.
Token cache. The refresh token is stored at
${XDG_CONFIG_HOME:-~/.config}/fcm-mcp/tokens.json (file mode 0600), so the
browser opens only once. Delete that file to force a fresh login / switch
accounts.
Keycloak client for browser login. Use a public client with PKCE
(S256) and a registered loopback redirect URI (http://127.0.0.1/*).
Set it via KEYCLOAK_CLIENT_ID.
Configuration (env)
| Var | Default | Meaning |
|-----|---------|---------|
| FCM_SERVER_URL | production | FCM Socket.IO server URL |
| KEYCLOAK_ISSUER | production realm | Keycloak realm issuer URL |
| KEYCLOAK_CLIENT_ID | production client | Keycloak client id (public PKCE client) |
| FCM_USERNAME | (unset) | Account for password login |
| FCM_PASSWORD | (unset) | Password — set this to use password login instead of the browser |
Pointing at another deployment:
"fcm": {
"command": "npx",
"args": ["-y", "foodchain-mcp"],
"env": {
"FCM_SERVER_URL": "https://your-fcm-server",
"KEYCLOAK_ISSUER": "https://your-keycloak/realms/<realm>",
"KEYCLOAK_CLIENT_ID": "<public-pkce-client>"
}
}Seat label
So the table can tell which account an agent runs on, the seat is named
<name> [<owner full name>] — <name> is the agent name from join_game's
displayName (default Agent), and <owner full name> is the authenticating
account's name (given_name family_name from the token, falling back to name,
then the username). e.g. join_game({ roomCode, displayName: "Gordon" }) shows
up as Gordon [Luis Hsu].
An agent can take its own seat even on the same account a human is using, via
seatTag (default mcp) — the human (untagged) and the agent (tagged) each get
a distinct seat. Each Keycloak identity+tag maps to exactly one seat.
Spectating (observer agents)
Call join_game({ roomCode, observer: true }) to join as a spectator. An
observer can't take_action, but it sees the full board via get_state /
get_raw_state / get_log, and wait_for_turn wakes on every live state
change (returns observed: true) — so a commentator/analyst agent can react
turn-by-turn. The watcher loop is simply:
wait_for_turn → get_state → (commentate) → repeat # until gameOver(Without observer: true, a normal join only becomes an observer as a fallback
when no player seat is available, and reports that in the result.)
Local development (from source)
npm install
npm run build -w mcp # type-check build → mcp/dist
npm run bundle -w mcp # single-file bundle → mcp/dist/fcm-mcp.mjs
npm test -w mcp # unit tests
node mcp/scripts/list-tools.mjs # spawn the server and list its toolsPoint your MCP client at the local build with env overrides for your dev stack:
"fcm": {
"command": "node",
"args": ["/path/to/FCM/mcp/dist/index.js"],
"env": {
"FCM_SERVER_URL": "http://localhost:3000",
"KEYCLOAK_ISSUER": "https://your-dev-keycloak/realms/<dev-realm>",
"KEYCLOAK_CLIENT_ID": "<dev-pkce-client>"
}
}For password login against a dev realm, add FCM_USERNAME/FCM_PASSWORD.
Troubleshooting
- Agent never gets a turn / "waiting for host" — a human host must click
Start, and every player must be ready.
join_gameauto-readies the agent. - Joined as
observer— the room was full or already in progress; start a fresh room. - Browser login rejected — the Keycloak client must be public, PKCE-enabled,
and have a loopback redirect URI (
http://127.0.0.1/*) registered. - Switch accounts — delete
~/.config/fcm-mcp/tokens.jsonto force a fresh login.
