Freeciv MCP server
by matpuk
README.md
# Freeciv MCP server for LLMs
The project gives **any LLM** a way to play Freeciv 3.2.6 through an
**MCP server** (Model Context Protocol). The bot (MCP server) drives one player
directly over the network protocol, while a graphical client is connected as an
**observer** — showing the game as if a human were playing.
## How it works
```
LLM (MCP host: Claude Desktop, opencode, Cursor, ...)
│ MCP (stdio/SSE): tools + resources + prompts
▼
Freeciv MCP server (Python; the bot process = one player)
│ JSON/TCP (state packets and orders)
▼
civserver (--enable-json)
▲
│ observer connection (same protocol)
GUI client (a human watches)
```
- The MCP server holds a persistent connection to `civserver` as a **player**.
- The LLM host connects to the MCP server and plays by calling **tools**.
- A human connects a GUI client as an **observer** and watches the game.
## Repository layout
| File | Purpose |
|-------------|-------------------------------------------------------------------------|
| `README.md` | This overview. |
| `DESIGN.md` | MCP server architecture + the interface contract (tools/resources/prompts). |
| `RULES.md` | Strategy-free rules reference fed to the LLM. |
## Installation
Requires **Python 3.10+** and [uv](https://docs.astral.sh/uv/). From the project
root:
```sh
uv sync
```
This creates a `.venv` and installs the dependencies plus the project itself in
editable mode; the `dev` group (`pytest`) is included by default (drop it with
`uv sync --no-dev`). Run the test suite with:
```sh
uv run pytest
```
and a module with `uv run python -m freeciv_mcp.run ...`. Build the distribution
artifacts (wheel + sdist into `dist/`) with:
```sh
uv build
```
## Key design decisions
1. **MCP server = the bot player, GUI = observer.** Freeciv natively supports
observers (`server/connecthand.c`: `pconn->observer`,
`game.glob_observers`). The bot logs in as a player; the GUI client connects
as an observer.
2. **Transport — the JSON protocol.** The server (and GUI client) are built with
`--enable-json` (autoconf) / `-Djson-protocol` (meson). Instead of the binary
encoding from `common/networking/packets.def`, the client speaks JSON
(`packets_json.c`, `dataio_json.c`, libjansson) — easy to parse in Python.
3. **The MCP server maintains a world model**, consuming state packets, and
exposes a stable JSON schema to the LLM. The LLM sees "semantic" state and
calls high-level tools (`move_unit`, `do_action`, `set_research`, ...), which
the server translates into network packets.
4. **Decision logic lives outside our code.** It is made by the LLM host. We only
ship the interface (tools/resources/prompts) and the game rules.
## Connecting an LLM host (a real game)
The MCP server connects to `civserver` as a player; the LLM host connects to the
MCP server and plays by calling tools. The `freeciv-play` prompt already feeds
the LLM the mechanics (`RULES.md`) and the turn loop; memory (turn history +
goals) arrives in `get_state` under the `memory` key.
1. **Start civserver** (JSON, flat map — how the bot addresses tiles):
```sh
freeciv-server --ruleset classic -p 5556 -e
# in the server console:
set aifill 2
set endturn 30
set timeout 60
set topology ""
set wrap ""
```
`--ruleset` can be `civ2civ3`/`multiplayer` (or any other modpack) — the bot
parses `PACKET_RULESET_*` regardless of ruleset. `set topology ""`/
`set wrap ""` force a flat square map and do not depend on the ruleset.
2. **Start the MCP server** (stdio — for opencode/Cursor/Claude Desktop):
```sh
python3 -m freeciv_mcp.run --host 127.0.0.1 --port 5556 --username llm-bot
```
To make the strategy and goals survive a restart, add
`--memory-file mem.json` (strategy + goals are saved to this JSON on change
and restored on start).
For SSE: `python3 -m freeciv_mcp.run --transport sse --mcp-port 8000 ...`.
Over SSE several MCP hosts (observers) can attach to one MCP server
simultaneously — `wait_for_my_turn` is now async and does not block the event
loop.
3. **Configure the host.** opencode (`opencode.json`) — an MCP server of type
`local` with command `python3 -m freeciv_mcp.run --host ... --port ...`;
Claude Desktop/Cursor — analogously in their MCP config (stdio command). Once
connected, the host sees the tools (`wait_for_my_turn`, `do_action`,
`move_unit`, `end_phase`, ...), the resources (`freeciv://rules`,
`freeciv://rules-reference`, `freeciv://state`, `freeciv://settings`) and the
prompt `freeciv-play`.
`freeciv://settings` (and the `settings` key in `get_state`) exposes the
current server settings by name (`timeout`, `endturn`, `maxplayers`,
`minplayers`, `aifill`, `fogofwar`, `size`, ...) with their value, range,
default and changeable flag — the parameters of the current match.
4. **LLM player without an external host (for measurements).** `llm_driver.py`
is a mini host that calls the LLM itself over an OpenAI-compatible HTTP API
and drives the same tool surface as an MCP host:
```sh
# a single game (server already up):
python3 -m freeciv_mcp.llm_driver --host 127.0.0.1 --port 5556 \
--model MODEL_ID --out metrics.json
# N games with metrics and a comparison against the deterministic bot:
python3 -m freeciv_mcp.run_metrics --games 3 --engine llm --aifill 1 \
--endturn 30 --seed 42 --out metrics.json
python3 -m freeciv_mcp.run_metrics --games 3 --engine bot --aifill 1 \
--endturn 30 --seed 42 --out baseline.json
# A different ruleset and loading a saved game:
python3 -m freeciv_mcp.run_metrics --engine bot --ruleset civ2civ3 ...
python3 -m freeciv_mcp.run_metrics --engine bot --load game.sav ...
# Two-level driver: the strategist revisits the plan every 5 turns.
python3 -m freeciv_mcp.llm_driver --host 127.0.0.1 --port 5556 \
--model MODEL_ID --strategist-every 5 --out metrics.json
```
Per-game metrics: survival, cities/technologies at turns 10/20/50, win/loss,
score, the list of errors (`ok:false`) and their reasons, plus latency:
`avg_turn_s`, `avg_llm_calls_per_turn`, the share of LLM/tool time
(`llm_time_pct`/`tool_time_pct`).
Model/endpoint/key are set via `--model`/`--base-url`/`--api-key` (or the
`LLM_MODEL`/`LLM_BASE_URL`/`LLM_API_KEY` environment variables). Provider
compatibility flags: `--reasoning-effort none` (for reasoning models),
`--tool-choice auto` and `--disable-thinking` (for thinking-mode models that
otherwise burn the whole output budget on reasoning and never call tools).
### Supported LLM API
The driver (`llm_driver.py` / `run_metrics.py`) talks the **OpenAI Chat
Completions API**: it `POST`s `<base-url>/chat/completions` with `model`,
`messages`, `tools` and `tool_choice`, so it works with **OpenAI itself and any
OpenAI-compatible endpoint** that supports function calling (`tools`). The
endpoint must accept function/tool calls — a plain text-only completion API is
not enough.
`--base-url` is the API root; the client appends `/chat/completions` to it.
Examples:
- OpenAI: `--base-url https://api.openai.com/v1`
- an OpenAI-compatible gateway/proxy: `--base-url https://llm.example.com/v1`
- a local OpenAI-compatible server (vLLM/Ollama): `--base-url http://localhost:8000/v1`
## State presentation to the LLM (mechanics, no strategy)
So the LLM can make legal decisions on its own, `get_state` adds ready-made
markers (not strategy):
- `visible_tiles[].found_city` — whether a city may be founded on the tile
(`true`/`false`) and, if not, `found_city_reason` (`terrain` — terrain without
cities, `has_city` — a city is already there, `too_close` — too close to
another city (`citymindist`), `foreign` — foreign territory);
- `units[].done` — the unit has no movement left; `units[].founder` — the unit
can found a city.
The `found_city` calculation mirrors `city_can_be_built_tile_only`
(common/city.c) and does not depend on the ruleset (the founder flag is read
from the `Found City` action enabler).
### Precise ruleset math (`visible_tiles.output`, buildable)
The ruleset is parsed in full (units, techs, buildings, terrains, extras,
actions, effects, multipliers, counters, resources, roads, bases, goods, ...),
so the state numbers are computed from the rules rather than approximated:
- `visible_tiles[].output = [food, shields, trade]` — the exact tile output,
computed from terrain + resource + road bonuses + irrigation/mine +
`Output_*` effects (ported `city_tile_output` / `get_target_bonus_effects` in
`effects.py`);
- buildable lists (`list_city_options`) come from a full evaluation of
`build_reqs`/`reqs` (effects and requirements, including the `Adjacent` range
used by Harbour/Port Facility), not from a client-side approximation.
### State summary (`summary`)
`get_state` also returns a compact factual `summary` — an aggregate that is
computed from the **full** snapshot (not the truncated lists), so the counters
stay correct even when `max_visible_tiles`/`max_units`/`max_cities` are set:
- `empire` — cities/population/gold/government, plus `research` (current tech,
`progress`/`cost`/`bulbs_per_turn`, `turns_left`);
- `threats` — `foreign_units_visible` (total count of visible foreign units),
`nearest_foreign_unit_distance`, `at_war`, and `units` (the nearest foreign
units, capped at 16, closest first) — foreign units did not appear in `units`
at all before;
- `foreign_cities` — visible foreign cities (capped at 16);
- `resources` — which resources are revealed and where (up to 8 tiles each);
- `expansion` — `founder_units_available`, `city_sites`, `city_sites_nearby`;
- `landmarks.huts` — huts in vision (by the `Hut` extra cause);
- `notes` — short factual strings ("N foreign units visible ...", "X turns
left", "Coal at [x,y]").
It is purely factual (what/where/how many) with no strategic advice.
## Combat calculator (`predict_combat_outcome`)
The combat win chance **is not computed in Python** — the server sends it in
`PACKET_UNIT_ACTIONS` (`action_probabilities`, half-percents), where for an
attack it is already computed via `get_defender()` + `unit_win_chance()`. Our
code only decodes and presents it:
- `list_unit_actions` adds to each action a `chance = {"pct_min", "pct_max",
"known"}` (percent = `val/2`; `known=false` for the server's "unknown"
`{0,200}`).
- The `predict_combat_outcome(unit_id, target, action="Attack")` tool queries
`UNIT_GET_ACTIONS` on the target tile and returns the percents for the combat
actions (`Attack`, `Suicide Attack`, `Conquer City*`, `Bombard*`,
`Collect Ransom`) together with factual context: the visible defender
(type/hp/veteran/activity), stack size, terrain and its `defense_bonus`,
defensive extras (`Fortress`), the city and its walls (`Visible_Walls` effect
via `effects.py`), and `killstack` from settings. If the defender is not
visible it honestly returns `{"ok": true, "known": false, "reason": "defender
not visible"}` with no invented number.
- `summary.threats.can_attack` — a list of one's own military units with movement
left that have an enemy unit/city adjacent; the chance is deliberately **not**
precomputed (each computation is a network round-trip with a timeout), the
model calls the tool once before a specific attack.
## Batched turn execution (`execute_turn`)
To avoid driving the LLM one tool-call per action (latency), there is the
`execute_turn(commands, stop_on_error?, end_turn?)` tool:
- `commands` — a list of `{"command": <tool name>, ...args}` objects, executed
strictly in order, each through the same validation path as the single tool
(including the `UNIT_ACTIONS` check and move verification);
- the reply is `{"ok", "results": [{"i", "command", "ok", "reason"?}, ...],
"errors": [...], "state": <fresh snapshot>}` (the state at the end, no
separate `get_state` needed);
- `end_turn=true` appends `end_phase` as the last command;
- guardrails: a 64-command limit, a nested `execute_turn` and a non-final
`end_phase` are rejected `{"ok": false}` **without sending**; an unknown
command is `ok:false` only for that command, the rest still run (unless
`stop_on_error=true`).
Batching does not save tokens (the model still prints the same arguments) — it
saves round-trips to the LLM and therefore turn time.
## Strategy layer (long-lived strategy + per-turn goals)
Separate from the short-term goals (`set_goals` — a plan for 1–3 turns) there is
a long-lived **strategy** — the model's plan that survives turns, reconnects and
context truncation:
- `set_strategy(text)` (tool) — saves the strategy (one string, up to ~1000
chars). The content is written by the LLM itself; the project's code/prompt
never invents strategy (the "mechanics, not strategy" invariant).
- The strategy is exposed as a top-level `strategy` key in `get_state` (not
inside `memory`, so it is not lost on truncation) and duplicated as a
`strategy: ...` line in `summary.notes`; the `freeciv-play` prompt prints it
as a block above the history/goals.
- **Persistence** — `run.py --memory-file <path>`: `strategy`+`goals` are
written to JSON on change and read at start (the turn history stays volatile).
A corrupt/missing file is ignored.
- **Two-level driver** — `llm_driver.py`/`run_metrics.py` with the
`--strategist-every N` flag (default 0 = off): on the first turn and then
every N turns a separate LLM call (the "strategist") with its own system
prompt and a reduced tool set (`get_state`, `set_strategy`, `set_goals`)
formulates/revises the strategy; orders and `end_phase` are not available to
it. The `--strategist-triggers` flag (in addition to `--strategist-every`)
makes the strategist revise the plan early when the situation shifts between
turns: government changed, war declared, or a city lost. The schedule and the
triggers share one call site, so a trigger firing on a scheduled turn does not
cause a double call; triggers are off by default.
## Chat (`send_chat`)
The bot can write to chat and read incoming messages:
- `send_chat(text, to="")` — a message to everyone (`to=""`) or privately to a
player (`to` = player name; encoded as `"name: message"`, as the Freeciv
client does).
- Incoming messages accumulate in `get_state.chat` (the last ~20, each
`{"turn", "kind", "conn_id", "text"}`; Freeciv colour markup
(`[c fg=...]...[/c]`) is stripped). `kind` is `chat` / `message_wall` /
`chat_error`; other game notifications (city built, unit lost) do not enter
the chat log.
- **Guardrail:** server commands travel through the same chat (`/set`, `/save`,
`/observe`, ...). Text starting with `/` is refused
(`{"ok": false, "reason": "server commands are disabled ..."}`) unless
`--allow-server-commands` is passed; the check is on the final message
(including the private prefix), so a recipient name cannot smuggle a command.
## Diplomacy: treaties (`list_diplomacy`, `propose_treaty`, `respond_to_treaty`, `cancel_pact`)
Full negotiations — meetings, treaty clauses, acceptance and pact breaking:
- `list_diplomacy()` — the full diplomatic state with every player
(`players[].diplstate`: `state`, `turns_left`, `has_reason_to_cancel`,
`contact_turns_left`, names from `common/player.h`) + active meetings and
their clauses (names from `common/diptreaty.h`).
- `propose_treaty(player, clauses=[{type, giver, value}])` — opens a meeting and
proposes clauses. `type` — by name or id (`Advance`, `Gold`, `Map`, `Seamap`,
`City`, `Ceasefire`, `Peace`, `Alliance`, `Vision`, `Embassy`, `SharedTiles`);
`giver` — `"me"`/`"them"` (whose clause); `value` is interpreted by type
(`Gold` — amount, `Advance` — tech id, `City` — city id, the rest — 0).
Values are not invented for the LLM.
- `respond_to_treaty(player, accept)` — accept the meeting (`accept=true`) or
decline (`accept=false`, cancels the meeting). Once both sides accept, the
server executes the agreed clauses and closes the meeting; the new diplomatic
state arrives as a separate `PLAYER_DIPLSTATE`.
- `cancel_pact(player, clause)` — break a pact (`Ceasefire`, `Peace`,
`Alliance`). Breaking `Ceasefire`/`Peace` = declaring war, `Alliance` →
`Armistice` (`cancel_pact_result`, `common/player.c`).
The stock C AI evaluates the treaty **clauses** (`ai/default/daidiplomacy.c`),
not the text: free text (`send_chat`) goes in parallel with the formal treaty
and only makes sense LLM↔LLM. The project does not write a diplomacy strategy —
the LLM decides with whom and about what to negotiate.
## Copilot mode (`--mode copilot`)
The bot does not play instead of the human — it connects as an **observer** of a
specific player, reads the state through their eyes and advises in chat:
```sh
python3 -m freeciv_mcp.run --host 127.0.0.1 --port 5556 \
--mode copilot --observe "PlayerName"
```
- `--observe` takes a **player name** (matched by `player_by_name_prefix` on the
server), not a username; the copilot's username must differ from the observed
player's username. Without `--observe` the start is refused: a global observer
sees the whole map, which is cheating relative to the human's fog of war.
- The connection does not send `NATION_SELECT_REQ`/`PLAYER_READY` — the copilot
does not become a player. The `/observe <player>` command goes out as an
ordinary chat packet (see the Chat section); server refusals (`can't observe
AI players`, etc.) are read from `state.chat_log` and surfaced as a clear
error at start.
- **Fog of war — the observed player's.** `get_state` substitutes
`my_player_id = <observed player's id>` and adds the top-level `perspective`
key (`{"player_id", "name", "observer": true}`), so `player`/`cities`/
`units`/`visible_tiles`/`summary` are filtered by the observed player's
visibility.
- **Read-only.** The command tools (`move_unit`, `do_action`,
`set_city_production`, `set_research`, `set_rates`, `change_government`,
`end_phase`, `execute_turn`, `propose_treaty`, `respond_to_treaty`,
`cancel_pact`) return `{"ok": false, "reason": "copilot mode is read-only"}`.
`get_state`, `list_unit_actions`, `list_city_options`, `list_diplomacy`,
`predict_combat_outcome`, `send_chat` and the copilot's own memory
(`set_goals`/`set_strategy` — they do not touch the game) stay available.
- **`advise(text)`** — advice in chat with a source marker (`[Copilot] …`), so
the human sees it in the GUI. It goes through its own path (not `send_chat`),
so the marker cannot be lost. The copilot reports facts and options from the
state; the strategy is written by the LLM itself (project invariant).
### Copilot driver (`--advise-on-turn`)
The copilot mode above hands the state to an MCP host that decides when to ask
the LLM. Without a host, the `llm_driver.py` driver connects as the observer
itself and posts one advice to chat at the start of each turn of the observed
player:
```sh
python3 -m freeciv_mcp.llm_driver --host 127.0.0.1 --port 5556 \
--username copilot --observe "PlayerName" --advise-on-turn \
--model MODEL_ID --base-url ... --api-key ...
```
- `--observe <player>` and `--advise-on-turn` are required and must be given
together; individually the start is refused (a global observer sees the whole
map — cheating). `--nation`/`--leader` are not used in this mode.
- The driver is a mini MCP host: `connect_copilot` enters as an observer
(`enter_observer`, no `NATION_SELECT_REQ`/`PLAYER_READY`) and builds
`FreecivMCP(read_only=True, observe_player=<id>)`; the `run_copilot` loop
waits on `conn.turn_event` and, on each change of the `turn` number, makes a
single LLM call with `build_copilot_prompt` + the state (through the observed
player's eyes), then executes the `advise` tool call. A second advice in the
same turn cannot happen (guarded by the turn number, since the observer sees
`START_PHASE`/`BEGIN_TURN` of every player).
- The model gets a reduced read-only tool set (`get_state`, `advise`); any
command tool is refused without execution. An attempt to give an order is also
refused (`copilot mode is read-only`).
- Exit — on game end (`game_over`) or connection loss.
## Saving/loading a game and reconnecting
Freeciv allows `/save` during a game, but `/load` only in pre-game (not on top
of a running game). So a restart "as the same player" works like this:
1. Save the game (`/save game` in the server console, or `save_game()` from
`run_metrics.py`). The file is written to `-s <directory>`.
2. Stop the server and start it with `-f game` (load at start) — or
`run_metrics --load game`.
3. The bot reconnects with the same `--username` and continues: the server
restores the player by username, and the MCP server re-accumulates the full
dump.
On an in-place pre-game load the server sends `PACKET_GAME_LOAD`
(`load_successful=true`) followed by the full state dump; `state.py` resets the
model on that packet (`reset()`) and rebuilds it from scratch, rather than
accumulating deltas on top of stale records.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues