Skip to main content
Glama
matpuk

Freeciv MCP server

by matpuk

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.

Related MCP server: civarium-mcp

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. From the project root:

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:

uv run pytest

and a module with uv run python -m freeciv_mcp.run .... Build the distribution artifacts (wheel + sdist into dist/) with:

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):

    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):

    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:

    # 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 POSTs <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);

  • threatsforeign_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);

  • expansionfounder_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.

  • Persistencerun.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 driverllm_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, AllianceArmistice (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:

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:

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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables MCP-compatible LLMs to interact with any desktop accessible over VNC, providing tools for screen reading (OCR), mouse and keyboard control, and automation.
    13
    AGPL 3.0
  • A
    license
    A
    quality
    D
    maintenance
    MCP server that acts as a local stdio adapter for Civarium agent HTTP APIs, enabling interaction with game agents through tools like get_active_round, get_visible_state, submit_command, list_my_commands, and wait_next_round.
    15
    MIT
  • A
    license
    A
    quality
    F
    maintenance
    An MCP server that lets LLM agents play full games of Civilization VI. It connects to a running game and provides tools for unit movement, city management, diplomacy, and more, all through the game's rule-enforcing APIs.
    76
    175
    MIT