Content
# caplab-mcp
Read-only MCP server exposing **Capitalism Lab** live game state to any
MCP-compatible client (Claude Desktop, Claude Code, etc.). Attaches to
a running `CapMain.exe` — on **Linux** via `/proc/<pid>/mem` (game
under Wine/Proton), on **Windows** via `ReadProcessMemory`
(`kernel32` / `psapi`). No ptrace, no network egress, no binary
modification, no modification of anything on disk.
34 read-only tools for pulling game state (calendar, economy, player
balance sheet, AI competitors, cities, firms, stocks, products, news),
plus exploration primitives (`read_raw`, `read_struct`), a
pause/resume-friendly snapshot + diff pair, and a human-in-the-loop
action gate with a small local web dashboard.
Every struct offset and virtual address
below is pinned against **Capitalism Lab v11.1.2**. Other game
versions are not supported.
## Requirements
- Python ≥ 3.10.
- A legitimately-licensed copy of Capitalism Lab v11.1.2. On Linux the
game should be running under Wine/Proton; on Windows it runs
natively.
- The MCP server must run as the **same user** as the game process —
required for `/proc/<pid>/mem` reads on Linux and for
`OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION)` on
Windows. The server never uses ptrace, never requests elevated
privileges, and never calls `WriteProcessMemory`.
- `mcp >= 1.27.0` (installed automatically via pip).
The server speaks stdio — hook it up to any MCP client.
### Claude Desktop config
Add the following to your `claude_desktop_config.json`
(`~/.config/Claude/claude_desktop_config.json` on Linux, see
[Claude Desktop docs](https://modelcontextprotocol.io/quickstart/user)
for other platforms). Replace `/path/to/caplab-mcp/.venv/bin/python`
with the actual path to the venv's Python on your machine:
```json
{
"mcpServers": {
"caplab": {
"command": "/path/to/caplab-mcp/.venv/bin/python",
"args": ["-m", "caplab_mcp"]
}
}
}
```
Restart Claude Desktop; the caplab MCP should appear on the next
launch. If the action dashboard's port (default `5599`) is free, the
HTTP server binds eagerly at startup and is reachable as soon as the
MCP process is up.
## Tools
| Tool | Purpose |
|------|---------|
| `attach(pid=None)` | Resolve PID + base + Misc pointer, cache for 30s. Idempotent. |
| `get_game_state()` | Date (JDN + Gregorian), pause, speed, difficulty, RNG seed, DLC flags. |
| `get_player()` | Player group (recno 2 by convention) with full balance sheet. |
| `get_company(group_recno)` | Any group by recno. O(1) chunk math via `resolve_pointer_paged`. |
| `list_companies(label=None, only_listed=False)` | Both GroupArrays, summary rows. Filter by recno-based label ("player" / "government" / "ai"). |
| `list_firms(owner_recno=None, firm_type=None, limit=200, verbose=False)` | FirmArray with filters. Default hides idle Government-owned infrastructure firms; pass `verbose=True` for the full list. |
| `get_firm(firm_recno, include_history=False)` | One firm; FirmRetail also returns walked Item chain (stock / price / quality / MTD profit). `include_history=True` adds a 36-month calendar-labelled revenue/profit/expense series. |
| `list_stocks()` | Listed groups with NationStock fields + computed market cap. |
| `list_cities()` / `get_city(recno)` | Town summary with real city names, population, growth %, living_standard, local_inflation, tax rates, business_activity_index. |
| `list_products()` | ItemResManager catalog (MegaClass / ProductClass / ItemRes). |
| `list_recent_news(since_jdn=None, limit=50)` | NewsArray records with rich schema (3 subject recnos, 2 int params, 2 double amounts, town context, map coords) + click-dispatch category label. Template-rendered display text is not included. |
| `get_economy()` | Economy singleton (GDP, cycle phase, interest rates, CPI, 5-year GDP history). |
| `read_raw(va, size=4, format="hex")` | Dump raw bytes from any VA. Formats: `u8`/`i8`/`u16`/`i16`/`u32`/`i32`/`u64`/`i64`/`f32`/`f64`/`hex`/`ascii`. Scalar formats with `size > element width` return a list. Always includes raw hex for verification. |
| `read_struct(ptr, fields)` | Decode many typed fields from one struct in a single sweep. `fields = [{name, offset, type, [size]}]`. Returns `{fields: {...}, covers_range: {start, end, hex}}`. Good for validating a hypothesized layout without editing `constants.py`. |
| `snapshot(tools=None)` | Run several tools and cache the combined payload under a `snap_NNNN` id. Last 10 snapshots retained. |
| `diff_from(snapshot_id, tools=None)` | Re-run the snapshot's tool set and return a structured diff. Lists of `recno`-keyed dicts are aligned by recno; numeric changes include a `delta`. |
| `request_action(action, reasoning="", impact="", timeout_sec=600)` | Human-in-the-loop action gate. The AI describes what it wants done in-game; the user sees it on the local dashboard at `http://127.0.0.1:5599` and clicks **Done ✓** or **Can't do ✗** with optional notes. Blocks until the user responds or the timeout hits. One pending at a time. |
### Map / save-file tools
These read the most-recent .SAV in your Capitalism Lab save folder
(default: `~/Documents/My Games/Capitalism Lab/SAVE`), decompress it,
and run useful queries. Results are small payloads — the full 12 MB
per-town JSON never crosses the wire. Cells, firms, groups, and raw
sites are cached in process memory; subsequent calls reuse the cache
until the file mtime changes.
| Tool | Purpose |
|------|---------|
| `get_map_dimensions(town_index)` | Width × height + cell_count for one town (1-based). |
| `get_tile(town_index, x, y)` | Full decoded cell — `type_flags`, every `is_*` predicate, `walker_terrain`, `feature_recno`, `site_recno`, `land_value`, `dev_level`, `traffic_index`, etc. |
| `get_map_summary(town_index)` | Aggregate stats: cell-category counts, ownership distribution (top 10), `land_value` & `traffic` max/mean, `dev_level` max, purchasable_cells. No per-cell data. |
| `get_terrain_grid(town_index)` | W*H bytes (one CATEGORY_CODE per cell) base64-encoded. Includes the codes legend so the caller can decode it. ~53 KB for a 200 × 200 map. |
| `list_tiles_matching(town_index, filter, limit=100, offset=0, sort_by=None, sort_desc=True)` | Filter every cell, return compact summaries. Filter keys: any boolean predicate (`is_road`, `is_plant`, `is_firm`, `purchasable`, …), any recno (`owner_recno`, `feature_recno`, `site_recno`, …), `category`, `tile_type`, plus numeric ranges (`land_value_min/max`, `dev_level_min/max`, `traffic_min/max`, `x_min/max`, `y_min/max`). |
| `find_buildable_land(town_index, limit=20, min_land_value=0)` | Tiles you could build on: purchasable + no firm/road/building/plant overlay, sorted by `land_value` desc. |
| `top_land_value_tiles(town_index, limit=20)` | Tiles ranked by `land_value` desc. |
| `traffic_hotspots(town_index, threshold=50, limit=50)` | Tiles where `traffic_index > threshold`. |
| `ownership_breakdown(town_index, limit=25)` | Distribution of `owner_recno` values + per-owner category breakdown. |
| `road_components(town_index, min_size=3, limit=20)` | Connected components of road cells (4-neighbor flood fill). Returns size + bbox, largest first. |
| `get_firm_at_cell(town_index, x, y)` | Resolve a firm cell through `FirmArray` → returns `firm_type_name` (specific, e.g. "Airport", "City Hall", "University"), `firm_class` (coarse C++ class), owner Group with label (`player`/`government`/`ai`), nation, bounding box. Returns `is_firm: false` for non-firm cells. |
| `list_firms_in_town(town_index, firm_type=None, owner_group_recno=None, owner_group_label=None, limit=200)` | Every distinct firm whose footprint touches the town, with full owner + type details. |
| `group_ownership_in_town(town_index)` | Cell counts grouped by **resolved** owner Group. Firm cells are dereferenced through `FirmArray`, so e.g. "Government owns 401 cells" instead of 50 separate firm-recno buckets. Includes a per-group `firm_types` breakdown. |
| `get_site_at_cell(town_index, x, y)` | Resolve the raw-material site (Aluminum / Chemical Minerals / Coal / Gold / Iron Ore / Lithium / Oil / Silica / Silver / Timber) at cell (x, y). Returns `{has_site: false}` for cells without a deposit. |
| `list_raw_deposits(town_index, item_name=None)` | Every raw-material deposit (mine / oil well / forest tile) inside one town. Optionally filter by `item_name` (e.g. `"Coal"`). |
The dumper resolves three lookup tables on top of the raw cell bytes:
**FirmRes** (142 entries — `Airport`, `City Hall`, `Police Station`, …),
**FirmArray::create_firm** firm-type → C++ class (44 entries),
and **SiteArray** raw-material item_type (1..10 → 10 raw materials).
See `Documentation/engine/Engine_Land.txt` in the research repo for the
canonical bit definitions and offsets.
## Response envelope
Every tool response is wrapped:
```json
{
"_meta": {
"game_date_jdn": 2448282,
"current_month_index": 12,
"paused": true,
"rng_seed": 2566239387
},
"data": { ... }
}
```
`_meta` is enough for a consumer to detect when two reads straddle a
game-day tick boundary. Compaction is applied to `data` for most
tools — fields with exact-zero numeric values, empty strings, and
empty collections are dropped by default to keep payloads small and
signal-dense. Identity fields (`recno`, `name`, `group_label`, …) are
always preserved.
## Monthly history format
When a tool returns a 36-entry monthly array (e.g. `get_firm(...,
include_history=True)`) each entry is labelled with the calendar month:
```json
{
"slot": 12,
"year": 1991,
"month": 1,
"label": "1991-01",
"is_current": true,
"value": 48293.50
}
```
Entries are oldest-to-newest. `slot` maps back to the engine's raw
index into the `double[36]` rolling window.
## Action dashboard
When `request_action` is called, a local web dashboard is served at
`http://127.0.0.1:5599` (or the next free port if 5599 is busy —
printed to stderr on startup). Open it in any browser:
- The pending action appears as a card with its title, the AI's
reasoning, and the expected impact. A running timer shows age +
remaining time.
- **Done ✓** records the action as executed. The notes textarea carries
any observations back to the AI.
- **Can't do ✗** records a rejection. The AI is expected to read your
reason out of the notes field and adjust its plan.
- History of past decisions lives at the bottom.
One pending action at a time. The AI's second concurrent
`request_action` call errors with `"another action is still pending"`.
The dashboard binds to `127.0.0.1` only — it is not exposed to any
other host on your network. The HTTP server is stdlib-only
(`http.server.ThreadingHTTPServer`). History is in-memory across the
lifetime of the MCP process only.
## Project layout
```
caplab-mcp/
├── README.md # this file
├── pyproject.toml # packaging; single dep on `mcp`
└── caplab_mcp/ # the MCP server package
├── __init__.py
├── __main__.py # `python -m caplab_mcp`
├── server.py # FastMCP server + all 18 tool bodies
├── reader.py # LiveReader — /proc/<pid>/mem on Linux,
│ # ReadProcessMemory on Windows,
│ # plus the paged-array walkers
├── constants.py # every VA and struct offset, with provenance
└── action_inbox.py # request_action dashboard (HTTP on localhost)
```
## Disclaimer
This project reverse-engineers the in-memory layout of Capitalism Lab
for interoperability and research purposes. **It is not affiliated
with, endorsed by, or sponsored by Enlight Software** or any of its
affiliates. Capitalism Lab and CapLab are trademarks of their
respective owner.
You need your own legitimately-licensed copy of the game.
Use at your own risk. Every address in this repository is pinned
against `CapMain.exe v11.1.2`. Different versions — past or future —
will not work until the address table is re-pinned.
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
markitdown
MarkItDown-MCP is a lightweight server for converting URIs to Markdown.
markitdown
Python tool for converting files and office documents to Markdown.
Filesystem
Node.js MCP Server for filesystem operations with dynamic access control.
TrendRadar
TrendRadar: Your hotspot assistant for real news in just 30 seconds.
mempalace
The highest-scoring AI memory system ever benchmarked. And it's free.
mempalace
The highest-scoring AI memory system ever benchmarked. And it's free.