Freeciv MCP server
Click on "Deploy 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., "@Freeciv MCP serverGive me an update on my game and suggest my next move"
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.
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
civserveras 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 |
| This overview. |
| MCP server architecture + the interface contract (tools/resources/prompts). |
| Strategy-free rules reference fed to the LLM. |
Installation
Requires Python 3.10+ and uv. From the project root:
uv syncThis 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 pytestand a module with uv run python -m freeciv_mcp.run .... Build the distribution
artifacts (wheel + sdist into dist/) with:
uv buildKey design decisions
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.Transport — the JSON protocol. The server (and GUI client) are built with
--enable-json(autoconf) /-Djson-protocol(meson). Instead of the binary encoding fromcommon/networking/packets.def, the client speaks JSON (packets_json.c,dataio_json.c, libjansson) — easy to parse in Python.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.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.
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 ""--rulesetcan beciv2civ3/multiplayer(or any other modpack) — the bot parsesPACKET_RULESET_*regardless of ruleset.set topology ""/set wrap ""force a flat square map and do not depend on the ruleset.Start the MCP server (stdio — for opencode/Cursor/Claude Desktop):
python3 -m freeciv_mcp.run --host 127.0.0.1 --port 5556 --username llm-botTo 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_turnis now async and does not block the event loop.Configure the host. opencode (
opencode.json) — an MCP server of typelocalwith commandpython3 -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 promptfreeciv-play.freeciv://settings(and thesettingskey inget_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.LLM player without an external host (for measurements).
llm_driver.pyis 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.jsonPer-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 theLLM_MODEL/LLM_BASE_URL/LLM_API_KEYenvironment variables). Provider compatibility flags:--reasoning-effort none(for reasoning models),--tool-choice autoand--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/v1an OpenAI-compatible gateway/proxy:
--base-url https://llm.example.com/v1a 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 (portedcity_tile_output/get_target_bonus_effectsineffects.py);buildable lists (
list_city_options) come from a full evaluation ofbuild_reqs/reqs(effects and requirements, including theAdjacentrange 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, plusresearch(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, andunits(the nearest foreign units, capped at 16, closest first) — foreign units did not appear inunitsat 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 theHutextra 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_actionsadds to each action achance = {"pct_min", "pct_max", "known"}(percent =val/2;known=falsefor the server's "unknown"{0,200}).The
predict_combat_outcome(unit_id, target, action="Attack")tool queriesUNIT_GET_ACTIONSon 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 itsdefense_bonus, defensive extras (Fortress), the city and its walls (Visible_Wallseffect viaeffects.py), andkillstackfrom 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 theUNIT_ACTIONScheck and move verification);the reply is
{"ok", "results": [{"i", "command", "ok", "reason"?}, ...], "errors": [...], "state": <fresh snapshot>}(the state at the end, no separateget_stateneeded);end_turn=trueappendsend_phaseas the last command;guardrails: a 64-command limit, a nested
execute_turnand a non-finalend_phaseare rejected{"ok": false}without sending; an unknown command isok:falseonly for that command, the rest still run (unlessstop_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
strategykey inget_state(not insidememory, so it is not lost on truncation) and duplicated as astrategy: ...line insummary.notes; thefreeciv-playprompt prints it as a block above the history/goals.Persistence —
run.py --memory-file <path>:strategy+goalsare 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.pywith the--strategist-every Nflag (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 andend_phaseare not available to it. The--strategist-triggersflag (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).kindischat/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-commandsis 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 fromcommon/player.h) + active meetings and their clauses (names fromcommon/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);valueis 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 separatePLAYER_DIPLSTATE.cancel_pact(player, clause)— break a pact (Ceasefire,Peace,Alliance). BreakingCeasefire/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:
python3 -m freeciv_mcp.run --host 127.0.0.1 --port 5556 \
--mode copilot --observe "PlayerName"--observetakes a player name (matched byplayer_by_name_prefixon the server), not a username; the copilot's username must differ from the observed player's username. Without--observethe 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 fromstate.chat_logand surfaced as a clear error at start.Fog of war — the observed player's.
get_statesubstitutesmy_player_id = <observed player's id>and adds the top-levelperspectivekey ({"player_id", "name", "observer": true}), soplayer/cities/units/visible_tiles/summaryare 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_chatand 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 (notsend_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-turnare required and must be given together; individually the start is refused (a global observer sees the whole map — cheating).--nation/--leaderare not used in this mode.The driver is a mini MCP host:
connect_copilotenters as an observer (enter_observer, noNATION_SELECT_REQ/PLAYER_READY) and buildsFreecivMCP(read_only=True, observe_player=<id>); therun_copilotloop waits onconn.turn_eventand, on each change of theturnnumber, makes a single LLM call withbuild_copilot_prompt+ the state (through the observed player's eyes), then executes theadvisetool call. A second advice in the same turn cannot happen (guarded by the turn number, since the observer seesSTART_PHASE/BEGIN_TURNof 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:
Save the game (
/save gamein the server console, orsave_game()fromrun_metrics.py). The file is written to-s <directory>.Stop the server and start it with
-f game(load at start) — orrun_metrics --load game.The bot reconnects with the same
--usernameand 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
Related MCP Connectors
MCP server for AI dialogue using various LLM models via AceDataCloud
MCP server for OpenMM — exposes market data, account, trading, and strategy tools to AI agents
MCP server exposing the Backtest360 engine API as tools for AI agents.
Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables MCP-compatible LLMs to interact with any desktop accessible over VNC, providing tools for screen reading (OCR), mouse and keyboard control, and automation.13AGPL 3.0
- AlicenseAqualityDmaintenanceMCP 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.15MIT
- AlicenseAqualityFmaintenanceAn 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.76175MIT
- AlicenseAqualityBmaintenanceAn MCP server that lets LLM agents play full games of Civilization VI.85MIT