Content
Here is the translated document:
# Skyline MCP
> Let Claude (or any MCP client) be your deputy mayor --
> Read KPI, draw districts, pave roads, plant trees, open bus lines, adjust taxes, approve loans, and archive for survival.
> All using natural language.
[](https://github.com/ZaneXo/cities-skylines-mcp/actions/workflows/ci.yml)
[](LICENSE)


**Skyline MCP** is a Model Context Protocol bridge for Cities: Skylines 1:
The in-game C# mod opens a local HTTP, and the Go-written MCP service wraps each HTTP endpoint into a tool.
LLM clients can read and write city states like calling APIs. 66 tools, 17 role-based skills, covering
everything from "check happiness" to "build a bridge between two buildings / add an S-shaped ramp to an interchange".
```
┌─────────────────┐ stdio ┌─────────────────┐ HTTP ┌──────────────────┐
│ MCP Client │ ──────► │ skyline-mcp │ ──────► │ SkylineMcp.dll │
│ (Claude / others) │ │ (Go service) │ :38910 │ (in-game mod) │
└─────────────────┘ └─────────────────┘ └──────────────────┘
│
▼
Cities.app (Singletons)
```
## Table of Contents
- [What it can do](#what-it-can-do)
- [Get started in 5 minutes](#get-started-in-5-minutes)
- [Skills: Role-based autonomous operation](#skills-role-based-autonomous-operation)
- [Tool reference (66)](#tool-reference-66)
- [Configuration](#configuration)
- [Development and testing](#development-and-testing)
- [Release](#release)
- [Troubleshooting](#troubleshooting)
- [Design highlights](#design-highlights)
- [Acknowledgments](#acknowledgments)
- [License](#license)
## What it can do
In one sentence: Expose the **real-time read + active write** port of CS1 city to LLM.
Here are some scenarios:
- **Check city health**: "Check RCI needs, power surplus, garbage accumulation, and this week's finances" --
Call 5 read endpoints in parallel and give a summary.
- **Fix service gaps**: "Death pile reached 40, find a flat area to build a crematorium" --
Call `find_buildable_area` to find a flat area, `place_building Crematory` to place it, and automatically deduct money.
- **Expand road network**: "A main road is needed from the eastern residential area to the central square, avoiding the lake" --
`place_road` adds `midX/midZ` to draw a Bezier curve, `upgrade_road` upgrades the bottleneck section to Large Road.
- **Open a bus loop**: "Equip the commercial area with a 4-station bus loop" --
`create_transit_line Bus` + `add_transit_stop` ×4, CS1 automatically connects into a loop.
- **Beautify and increase land value**: "Plant 40 trees and 20 streetlights along the main street" --
`plant_tree` + `place_prop` with `find_buildable_area`.
- **Take insurance before a risky operation**: "Save the current situation as `before-rezone-001`" --
`save_game`, CS1 loads UI and rolls back directly.
Skills are arranged at a higher level: calling the `mayor` skill will **spawn up to 16 sub-agents** (CFO, Planning Bureau, Construction Bureau,
Surveying Institute, Health Commission, Public Security + Fire Department, Education Bureau, Water Utility, Power Utility, Sanitation Bureau, Transportation Bureau,
Highway Department, Environmental Protection Bureau, Tourism Bureau, Audit Bureau, Municipal Office) based on the current city stage (founding / growing / mature),
each inspecting and making decisions.
## Get started in 5 minutes
**Prerequisites**: macOS (verified on Apple Silicon; Windows / Linux paths configured but untested),
Steam version of CS1, `dotnet` SDK 8+ (`brew install dotnet`), `go` 1.22+.
```bash
# 1. Clone
git clone https://github.com/ZaneXo/cities-skylines-mcp.git
cd cities-skylines-mcp
# 2. Compile and install mod to CS1 Mods directory
cd mod && dotnet build -c Release -p:Install=true && cd ..
# 3. Compile MCP service
cd mcp-server && go build -o ./bin/skyline-mcp . && cd ..
# 4. Add to Claude Code (or your MCP client) configuration file
# macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
cat <<EOF
{
"mcpServers": {
"skyline": {
"command": "$(pwd)/mcp-server/bin/skyline-mcp"
}
}
}
EOF
# 5. Start CS1, select "Skyline MCP Bridge" in Content Manager → Mods
# 6. Load any save
# 7. Restart MCP client with ⌘Q
# 8. Try in conversation: "Check city status", "Find a place to build a park", "Draw a Bezier main road"
```
> Want to use a different port/path? See [configuration](#configuration) for environment variable table.
> The entire toolset listens on `127.0.0.1:38910` by default, without authentication (loopback is sufficient);
> If you want to bind an external interface, **must** set `SKYLINE_MOD_TOKEN`.
## Skills: Role-based autonomous operation
There are **17 SKILL.md** under `skills/`, each corresponding to a role with a clear responsibility,
reorganized into 6 functional groups based on the **Chinese municipal system** (Planning Bureau / Construction Bureau / Health Commission / Public Security + Fire Department, etc.).
Each lever (policy, budget, plot, construction) **has one and only one writer** -- see
[`skills/README.md`](skills/README.md) for the permission matrix. Thresholds are concentrated in
[`skills/kpi_thresholds.json`](skills/kpi_thresholds.json), no need to grep modify across 17 files.
### 🏛 Strategic Layer
| Skill | Corresponding Municipal Agency | Responsibility |
|---|---|---|
| `mayor` | Mayor's Office | Scheduler: pull KPI → spawn sub-agents in parallel based on stage (founding/growing/mature) → make city-level decisions |
| `urban-planner` | Planning Bureau | District painting (R/C/I/O); district creation; long-term land planning |
| `city-builder` ⭐ | Construction Bureau | Physical placement during construction: initial roads, hospitals, schools, water plants, fire stations |
| `cartographer` ⭐ | Surveying Institute | Spatial intelligence: `map/grid` + `map/coverage` cached to `$STATE/`, read-only |
| `theme-architect` ⭐ | Major Project Office | Theme area arrangement: High Line / Marina Bay / Chongqing Interchange / Shibuya X Bridge / Hong Kong Central |
### 🛣 Infrastructure Layer
| Skill | Corresponding Municipal Agency | Responsibility |
|---|---|---|
| `road-engineer` ⭐ | Highway Department | Road skeleton: curved main roads, bridges/tunnels, `upgrade_road` |
| `transit-operator` | Transportation Bureau | **Unified holding** bus / subway / tram / ferry / cable car lines + budget + policy (merged old transit-director + old infra's transit part) |
| `power-director` | Power Utility | Power budget; overload ×2 rounds of wind turbines |
| `water-director` ⭐ | Water Utility | Water budget; overload ×2 rounds of water plants |
| `sanitation-director` | Sanitation Bureau | Garbage budget; crisis construction of incinerators; Recycling / AutomatedSorting policy |
### 🏥 Public Service Layer
| Skill | Corresponding Municipal Agency | Responsibility |
|---|---|---|
| `health-director` ⭐ | Health Commission | Medical + funeral budget; death wave construction of crematorium; coverage gap construction of hospitals |
| `safety-director` ⭐ | Public Security + Fire Department | Police + fire budget; construction of police stations / fire stations according to coverage gaps |
| `education-director` ⭐ | Education Bureau | Three-level schools; when W demand is high, enable Industry 4.0 / WorkSafety |
### 🌿 Environment, 💰 Economy, 🕵 Governance
| Skill | Corresponding Municipal Agency | Responsibility |
|---|---|---|
| `environment-director` | Environmental Protection Bureau | Pollution policy; local beautification supplement policy with `plant_tree` + `place_prop` |
| `cfo` | Finance Bureau | Tax rate + budget cap; hard security fence (electricity >70% rejects power cut) |
| `tourism-director` | Commerce + Tourism | Tourism / festival policy; public transportation collaboration |
| `policy-reaper` ⭐ | Audit Bureau | Unique "delete proposal" right: sweep outdated policies / 0 passenger traffic bus / empty district, propose to mayor for cleanup |
| `secretary` | Municipal Office | KPI writing to SQLite; markdown briefing every 15 minutes |
⭐ = 9 new roles added in this reorganization. Before reorganization, there were 11 mixed skills with unclear boundaries,
and 3 new construction roles and 1 cleanup role filled the "tool orphan" issues.
All skills use `${SKYLINE_MOD_URL:-http://127.0.0.1:38910}` +
`${SKYLINE_STATE_DIR:-/tmp/skyline-mayor}` to parse configuration, single shell setting can follow.
Cross-departmental communication uses a unified format:
`[HH:MM:SS] <ROLE> → <TARGET>: <directive> | reason=<KPI>` written to
`$STATE/log.txt`.
## Tool List
Grouped by capability domain. **All endpoints have been verified to work on a real CS1 Managed DLL with `dotnet build`**
(verified through `mod/Stubs/` stub assemblies, without game DLLs; local + `real-game-build.yml` self-hosted workflow runs).
<details>
<summary><b>City KPI Reads (11)</b></summary>
| Tool | Endpoint | Content |
|---|---|---|
| `ping` | `GET /health` | Mod is alive + archive loaded |
| `get_city_stats` | `GET /city/stats` | Population, cash, happiness |
| `get_demand` | `GET /city/demand` | RCI demand (0–100) |
| `get_economy` | `GET /city/economy` | Cash, weekly income/expense/net flow |
| `get_population` | `GET /city/population` | Total population + age group split |
| `get_environment` | `GET /city/environment` | Pollution, land price, crime rate, garbage |
| `get_services` | `GET /city/services` | Public service capacity and usage, health |
| `get_time` | `GET /city/time` | Game date, paused, speed |
| `get_tax_rates` | `GET /city/tax` | Current tax rates for each district |
| `get_policies` | `GET /city/policies` | All named policies + enabled status |
| `get_budgets` | `GET /city/budget` | Budget for each service, day/night |
</details>
<details>
<summary><b>Spatial Discovery (11)</b></summary>
| Tool | Endpoint | Content |
|---|---|---|
| `list_building_types(service?, limit?, offset?)` | `GET /prefabs/buildings` | Directory of placeable buildings (about 3000 entries). **With `aiKind` field** (standardized classification, see below) + `unlockedAtMilestone` (CS1 milestone name / empty string = always unlocked) |
| `list_road_types(service?, category?, limit?, offset?)` | `GET /prefabs/roads` | NetInfo prefab directory |
| `list_rail_types(category?, limit?, offset?)` | `GET /prefabs/rails` | **Rail / Metro / Tram / Monorail / Cable Car prefab directory** (subset of PublicTransport) |
| `list_path_types(category?, limit?, offset?)` | `GET /prefabs/paths` | **Sidewalk / Pedestrian Bridge prefab directory** (subset of Beautification, filtered to exclude roads) |
| `list_tree_types(limit?, offset?)` | `GET /prefabs/trees` | TreeInfo directory |
| `list_prop_types(limit?, offset?)` | `GET /prefabs/props` | PropInfo directory (streetlights, benches, sculptures, landmark signs…) |
| `get_terrain(x, z, radius?)` | `GET /terrain` | Surface height, slope, water presence, buildability |
| `list_buildings_in_area(x1,z1,x2,z2,service?,limit?)` | `GET /buildings/in-area` | Existing buildings within a rectangle |
| `list_segments_in_area(x1,z1,x2,z2,service?,limit?)` | `GET /segments/in-area` | Existing road/rail segments |
| `get_zoning_at(x, z)` | `GET /zoning/at` | Current zoning type at a world point |
| `find_buildable_area(w, h, centerX?, centerZ?, range?)` | `GET /buildable/find` | Find a flat, water-free, buildable area within a rectangle |
`aiKind` values (standardized classification based on BuildingAI subclass names):
`Police` / `Fire` / `Hospital` / `Deathcare` / `Education` / `Electricity` /
`Water` / `Garbage` / `Park` / `Monument` / `Transit` / `Tourism` /
`Residential` / `Commercial` / `Industrial` / `Office` / `Unknown` / `Other`.
</details>
<details>
<summary><b>City Management (6 writes; enqueued to simulation thread via SimulationManager.AddAction)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `set_tax_rate(zone, rate)` | `POST /city/tax` | Adjust single district tax rate (0–29) |
| `set_policy(name, enabled)` | `POST /city/policy` | Toggle a city-level policy |
| `set_budget(service, day?, night?)` | `POST /city/budget` | Adjust budget sliders (50–150) |
| `pause_simulation` | `POST /sim/pause` | Pause simulation |
| `resume_simulation` | `POST /sim/resume` | Resume simulation |
| `set_simulation_speed(value)` | `POST /sim/speed` | Set simulation speed (1=slow, 2=medium, 3=fast) |
</details>
<details>
<summary><b>Construction (14)</b></summary>
Roads: 5 tools support **straight, curved, bridges/tunnels, in-place upgrades, one-click reversal of one-way roads**;
Railways: 2 independent tools (metros/monorails/trams also use these);
Sidewalks: 2 tools support **ground-level sidewalks + elevated pedestrian bridges** (`elevation > 6m` automatically bypasses "intersecting building" checks);
Also includes an **on-ramp helper** (Go-side computation of Bezier control points):
| Tool | Endpoint | Effect |
|---|---|---|
| `place_building(prefab, x, z, angle?, needMoney?, dryRun?, fixedHeight?)` | `POST /building` | Place a building. **OBB collision avoidance + road access + milestone/plot unlocking** with full fencing. `dryRun` only estimates, does not place |
| `demolish_building(id)` | `POST /building/demolish` | Demolish a building by ID |
| `place_road(prefab, x1?, z1?, x2?, z2?, midX?, midZ?, elevation?, startNode?, endNode?, ...)` | `POST /road` | Construct a road segment. **`midX/midZ` → Bezier curve**; **`elevation` → bridge/tunnel** (in meters, with Elevated/Tunnel variant prefabs); endpoints can reuse existing nodes to extend |
| `delete_road(id, keepNodes?)` | `POST /road/delete` | Delete a road segment (default also releases nodes) |
| `upgrade_road(segmentId, toPrefab)` | `POST /road/upgrade` | **In-place upgrade road segment** (small→medium→large). Preserves endpoints → intersections not destroyed. Only charges difference |
| `reverse_road(segmentId)` | `POST /road/reverse` | **One-way road direction reversal**: flips NetSegment.Flags.Invert bit, preserving nodes + intersections + traffic data. No side effects on two-way roads |
| `place_highway_ramp(prefab, fromX, fromZ, toX, toZ, elevation?, curveStrength?, ...)` | adjusted `POST /road` (Go-side aggregation) | **S-curve on-ramp helper**: Go-side computes midX/midZ based on "vertical offset = length × curveStrength (default 0.25)" |
| `place_rail(prefab, x1?, z1?, x2?, z2?, midX?, midZ?, elevation?, ...)` | `POST /rail` | **Railway/Metro/Tram/Monorail/Cable Car dedicated endpoint**. Same parameters as `place_road`; **subway tunnels** (elevation < -0.5) bypass "intersecting building" checks; rejects Road/Beautification prefabs |
| `delete_rail(id, keepNodes?)` | `POST /rail/delete` | Delete a rail segment |
| `place_path(prefab, x1?, z1?, x2?, z2?, midX?, midZ?, elevation?, ...)` | `POST /path` | **Sidewalk / Pedestrian Bridge**. Same parameters as `place_road`; **`elevation > 6m` → Pedestrian Bridge** (intersecting building checks automatically bypassed); ground-level still strictly prevents building intersections |
| `delete_path(id, keepNodes?)` | `POST /path/delete` | Delete a sidewalk segment |
| `plant_tree(prefab, x, z, dryRun?)` | `POST /tree` | Plant a tree (automatically rejects placement on roads/buildings/water) |
| `remove_tree(id)` | `POST /tree/remove` | Remove a tree |
| `place_prop(prefab, x, z, angle?, dryRun?)` | `POST /prop` | Place a prop |
| `remove_prop(id)` | `POST /prop/remove` | Remove a prop |
And `add_transit_stop` now takes a `buildingId` parameter — **directly snaps to station building**
(Bus Terminal / Train Station / Metro Station / Cargo Hub), no need to calculate coordinates:
```bash
# Old way: coordinates + expected station position
POST /transit/line/stop?id=1&x=320&z=480
# New way: snap to station
POST /transit/line/stop?id=1&buildingId=42
```
</details>
<details>
<summary><b>Zoning (2)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `zone_area(x1, z1, x2, z2, type, dryRun?)` | `POST /zone` | Zone a rectangular area (only grid cells adjacent to roads are effective; occupied grid cells are skipped) |
| `dezone_area(x1, z1, x2, z2, dryRun?)` | `POST /dezone` | Clear zoning in a rectangular area |
</details>
<details>
<summary><b>Bulk Dispatch (2; Go-side aggregation)</b></summary>
| Tool | Effect |
|---|---|
| `place_buildings(items)` | Run ≤100 `place_building` requests; returns results item-by-item, partial failures visible |
| `place_roads(items)` | Bulk version of `place_road`; constructs a whole road network at once |
</details>
<details>
<summary><b>Public Transit Lines (4)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `create_transit_line(prefab)` | `POST /transit/line` | Create an empty transit line (Bus / Metro / Train / Tram / Ship / Plane / Monorail / CableCar / Trolleybus / Helicopter) |
| `add_transit_stop(id, x, z, index?)` | `POST /transit/line/stop` | Add a stop to a transit line; CS1 automatically closes the loop |
| `delete_transit_line(id)` | `POST /transit/line/delete` | Delete a whole transit line |
| `get_transit_lines` | `GET /transit/lines` | List all constructed transit lines |
</details>
<details>
<summary><b>Districts (4)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `create_district(name?)` | `POST /district/create` | Create a new district (1–127) |
| `paint_district(id, x1, z1, x2, z2)` | `POST /district/paint` | Paint a rectangular territory for a district (19m grid cells) |
| `set_district_policy(id, name, enabled)` | `POST /district/policy` | Toggle a policy for a specific district ("heavy vehicle ban", "industry 4.0"…) |
| `list_districts` | `GET /district/list` | List all constructed districts |
</details>
<details>
<summary><b>Plot Purchase (2)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `get_tiles` | `GET /tiles` | Query 9×9 plot grid: purchased / purchasable |
| `buy_tile(x, z)` | `POST /tile/buy` | Purchase an adjacent plot |
</details>
<details>
<summary><b>Milestones + Loans (4)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `get_milestones` | `GET /city/milestones` | List milestone names + unlocked status (via `UnlockManager.Unlocked(info)`) |
| `get_loans` | `GET /city/loans` | List loan details + current activation status + `amountLeft` (reflects `m_loans[]`) |
| `take_loan(index)` | `POST /city/loans/take` | Take out a loan; accurately reports `taken:true/false` via `m_loans` before/after comparison |
| `pay_loan(index)` | `POST /city/loans/pay` | Pay off a loan in full, returns `amountPaid` |
</details>
<details>
<summary><b>Save + Screenshot (3; operational critical tools)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `save_game(name)` | `POST /save` | Save the current city with a name + write to disk. **Claude saves a copy before making aggressive write operations**; CS1 can roll back directly from UI |
| `get_screenshot(w?, h?)` | `GET /screenshot` | Render the current view as a base64 PNG, allowing Claude to "see" the city |
| `get_traffic(top?, service?)` | `GET /city/traffic` | City-wide road traffic density avg/max + top-N congestion hotspots |
</details>
<details>
<summary><b>Phase 5 City Summary (2; spatial awareness core)</b></summary>
| Tool | Endpoint | Effect |
|---|---|---|
| `get_map_grid(resolution?)` | `GET /map/grid` | Pack the entire map into an N×N grid, with each cell `[h, water, road, zone, district, occupied]`. **One call replaces about 5000 point queries**. Default 64×64 (4096 cells, ~270m/cell). |
| `get_map_coverage(service, resolution?)` | `GET /map/coverage` | Service coverage heatmap (0-255). `service` ∈ {FireCover, PoliceDepartment, HealthCare, EducationElementary, EducationHighSchool, EducationUniversity, DeathCare, …} |
`mayor` skill now caches the results of these two endpoints in `$STATE/map.json` +
`$STATE/coverage-*.json` at step 1, allowing downstream sub-agents to read directly from disk with zero HTTP calls.
</details>
## Configuration
### MCP Client Configuration Example
```json
{
"mcpServers": {
"skyline": {
"command": "/Users/<you>/Projects/skyline/mcp-server/bin/skyline-mcp",
"env": {
"SKYLINE_MOD_HOST": "127.0.0.1",
"SKYLINE_MOD_PORT": "38910"
}
}
}
}
```
Environment Variables
Both sides (mod + Go) read the same set of environment variables. All are optional; just set them once in the shell where the MCP client is started (and `skyline-mcp` and the Mac Steam process will inherit).
| Variable | Who reads | Default | Effect |
|---|---|---|---|
| `SKYLINE_MOD_HOST` | mod, Go | `127.0.0.1` | HTTP bind address / target host |
| `SKYLINE_MOD_PORT` | mod, Go | `38910` | HTTP port |
| `SKYLINE_MOD_TOKEN` | mod, Go | _not set_ | Shared bearer token. If set, mod rejects requests without `Authorization: Bearer …`. |
| `SKYLINE_MOD_TIMEOUT_SEC` | Go | `5` | Tool call HTTP timeout in seconds |
| `SKYLINE_MOD_URL` | `skills/*/SKILL.md` | `http://127.0.0.1:38910` | Base URL for skill curl |
| `SKYLINE_STATE_DIR` | Skill scripts + prompts | `${TMPDIR:-${TEMP:-/tmp}}/skyline-mayor` | Home of `log.txt` / `state.json` / `city.db` / `report.md` |
| `SKYLINE_MOD_INSTALL_DIR` | csproj | OS default | Target for `-p:Install=true` dll copy |
| `CS1_MANAGED_PATH` | csproj | OS default Steam | Path to find `ICities.dll` during build |
### Cross-Platform Installation Paths
`-p:Install=true` resolves in the following priority (first non-empty one takes effect):
| Source | Example |
|---|---|
| `-p:ModInstallDir=...` build argument | `dotnet build -p:Install=true -p:ModInstallDir=/custom/path` |
| `SKYLINE_MOD_INSTALL_DIR` environment variable | `SKYLINE_MOD_INSTALL_DIR=/custom/path dotnet build -p:Install=true` |
| Mac default | `~/Library/Application Support/Colossal Order/Cities_Skylines/Addons/Mods/SkylineMcp/` |
| Windows default | `%LOCALAPPDATA%\Colossal Order\Cities_Skylines\Addons\Mods\SkylineMcp\` |
| Linux default | `~/.local/share/Colossal Order/Cities_Skylines/Addons/Mods/SkylineMcp/` |
Game installation path has the same priority: `-p:CS1ManagedPath=...` > `CS1_MANAGED_PATH` > OS default Steam path.
## Development and Testing
### Directory Overview
```
skyline/
├── mod/ # C# in-game mod (.NET Framework 3.5)
│ ├── Mod.cs # IUserMod + SerializableDataExtensionBase
│ ├── SkylineMcp.csproj # Supports UseStubs=true for stub/real compilation
│ ├── Bridge/ # 15 .cs files, each for a group of endpoints
│ │ ├── HttpBridge.cs # HttpListener + routing table + Bearer auth
│ │ ├── Endpoints.cs # KPI read + tax/policy/budget/sim control
│ │ ├── Discovery.cs # Spatial discovery (prefab directory, terrain sampling, find_buildable_area)
│ │ ├── Build.cs / Road.cs / Zone.cs / Tree.cs / Prop.cs / Transit.cs / District.cs / Tile.cs
│ │ ├── Milestone.cs # Milestone read + loans (including reflection read m_loans[])
│ │ ├── Save.cs / Screenshot.cs
│ │ └── Sim.cs # listener-thread → sim-thread sync bridge
│ └── Stubs/ # For CI: minimal stubs for game DLLs, allowing compilation without CS1 installation
├── mod-tests/ # xUnit suite (net8 + roll-forward to net10), 98 tests
│ ├── HttpBridgeTests.cs # Routing, auth, ArgumentException → 400, etc.
│ ├── EndpointsReadTests.cs / EndpointsWriteTests.cs / BuildAndZoneTests.cs / Phase4Tests.cs
│ └── TestFixtures/BridgeFixture.cs # Spins up real HttpListener + resets stub state
├── mcp-server/ # Go MCP service (stdio)
│ ├── main.go
│ ├── internal/bridge/client.go # Talks to mod HTTP; supports SKYLINE_MOD_TOKEN
│ ├── internal/tools/tools.go # Registry for 66 MCP tools
│ ├── internal/tools/enum_drift_test.go # Contract test: compares Go zone/budget enums with C# source
│ └── scripts/e2e_fake.sh # Smoke test without game dependency
├── skills/ # 11 character skills (mayor orchestration + 10 directors + secretary)
├── scripts/
│ ├── release.sh / release.ps1 # Builds mod + cross-compiles Go binary (mac/linux/win)
│ └── validate_skills.sh # Validates HTTP paths in skills are in mod routing table
└── .github/
├── workflows/ci.yml # Three jobs: go, mod, skills
├── workflows/real-game-build.yml # Self-hosted runner for real DLL build
└── dependabot.yml # Weekly updates for go/nuget/actions
```
### Three Build Configurations
```bash
# Real compilation: links against real game DLLs in Cities.app/Managed/ (CS1 must be installed locally)
cd mod && dotnet build -c Release
# CI compilation: uses stubs in mod/Stubs/, no game required
cd mod && dotnet build -c Release -p:UseStubs=true
# Full local validation
cd mcp-server && gofmt -l . && go vet ./... && go test -race -cover ./...
cd mod && dotnet format SkylineMcp.csproj --verify-no-changes
cd mod-tests && dotnet test -c Release
```
`mod-tests/` recompiles `mod/Bridge/*.cs` and `mod/Stubs/*.cs` into a single net8 assembly for xUnit testing, spinning up a real `HttpListener` on a local free port for end-to-end validation of routing → handler → JSON output.
### CI
`.github/workflows/ci.yml` requires all three jobs to pass for each push/PR:
- **`go`** —— `gofmt -l`, `go vet`, `golangci-lint v2.12.2`, `go test -race -cover`.
Includes **enum-drift contract test**, comparing Go `validTaxZones`/`validBudgetServices` with C# source in `mod/Bridge/Endpoints.cs` to prevent drift.
- **`mod`** —— `dotnet build -p:UseStubs=true` + `dotnet format --verify-no-changes`
+ 98 xUnit tests.
- **`skills`** —— `scripts/validate_skills.sh` ensures each `skills/*/SKILL.md` references HTTP paths that exist in `HttpBridge.cs` routing table.
Main branch has branch protection: requires PR + all three CI jobs green + linear history, forbids force push and deletion.
**106 xUnit tests** (98 Phase 4 + 8 Phase 5 map_grid/coverage).
**Real end-to-end smoke test**: CI only checks types + JSON shape; actual endpoint integration with real CS1 managers requires running on a machine with the game installed, using [`docs/SMOKE-TEST.md`](docs/SMOKE-TEST.md), which includes ~40 curl commands covering Phase 4 + Phase 5 endpoints, including `Loan` struct field name verification.
### Adding New Endpoints in 3 Steps
1. **mod side**: add handler in `mod/Bridge/<group>.cs`, then register in `mod/Bridge/HttpBridge.cs` routing table. For write operations —— **must** use `Sim.Run(...)` to queue work on the simulation thread, cannot modify game state directly from listener thread.
2. **stub side**: if handler accesses game types/fields not in `mod/Stubs/`, add stubs. CI will fail with error message indicating what's missing.
3. **MCP side**: add a tool in `mcp-server/internal/tools/tools.go` tool slice. For read endpoints use `passthrough(c, "/...")`, for write endpoints use `postWithQuery(c, "/...", required, optional)`. Update `tools_test.go::TestRegisterAddsExpectedToolCount` expected list.
4. **Build both sides, restart game and MCP client**.
## Release
```bash
# Local packaging (requires CS1 installed locally), produces dist/skyline-mcp-vX.Y.Z.{tar.gz,zip}
scripts/release.sh v0.2.0
# One-step: tag + push + gh release create
scripts/release.sh v0.2.0 --publish
```
For Windows, use `scripts/release.ps1` (equivalent functionality).
CI does not produce release artifacts directly —— due to copyrighted game DLLs (`ICities.dll`, `ColossalManaged.dll`, `Assembly-CSharp.dll`, `UnityEngine.dll`) not being pushed to public runners. The repository has `.github/workflows/real-game-build.yml`: self-hosted runners labeled `[self-hosted, cs1-installed]` can trigger real builds and upload `SkylineMcp.dll` artifacts.
## Troubleshooting
| Symptom | Possible Cause | Fix |
|---|---|---|
| Mod does not appear in Content Manager | dll references something CS1's Mono cannot resolve | Check `~/Library/Logs/Unity/Player.log` for `FileNotFoundException`; adjust target framework/references |
| `curl: (7) connection refused` | Savegame not loaded (bridge starts on `OnLevelLoaded`) | Load any savegame |
| New endpoint returns 404 | CS1 reloads cached assembly from previous save | Restart game ( ⌘Q + Steam restart not enough). Diagnostics: old route 200 / new route 404 = cached old assembly |
| No `mcp__skyline__*` tools in MCP client | Client did not read configuration | Restart; check config file is valid JSON |
| All numbers are 0 | New game has no citizens | Wait for simulation to run or load developed savegame |
| `zone_area` reports `cellsInRect=0` on new roadside | `place_road` uses low-level `NetManager.CreateSegment` which does not auto-generate zone block (UI does) | Draw zone along player-built road (or previously simulated road); consider calling `NetManager.UpdateSegment` in mod |
| `dotnet build` cannot find ICities.dll | CS1 not in standard Steam path | `dotnet build -p:CS1ManagedPath=/your/path` or export `CS1_MANAGED_PATH` |
| Bearer 401 | Mod side `SKYLINE_MOD_TOKEN` and Go side do not match | Set same token in same shell; mod startup logs will print "(bearer auth)" |
## Design Highlights
- **Mod is single source of truth for data structures**. Go service intentionally kept thin —— forwards JSON as-is. Adding fields to existing endpoints does not require Go changes.
- **Defaults to `127.0.0.1`**, no auth. Sufficient for loopback single-machine use. To bind to external interface → **must set `SKYLINE_MOD_TOKEN`**. `HttpBridge.Handle` is sole entry point between external caller and your savegame.
- **Targets net35**. CS1's old Mono lacks `netstandard` facade —— targeting `netstandard2.0` compiles but fails to load. net35 + Microsoft reference-pack NuGet enables portable builds on Mac.
- **District index 0 aggregates entire city**. Most KPIs read from `DistrictManager.instance.m_districts.m_buffer[0]` and nested structs (e.g., `m_populationData` / `m_groundData` / `m_productionData` / `m_usageData` / `m_residentialConsumption`).
- **Write operations go through simulation thread**. `Sim.Run(...)` uses `SimulationManager.AddAction` + `ManualResetEvent` to sync listener thread with sim thread. Modifying game state directly from listener thread causes simulation desync and savegame corruption.
## Acknowledgments
Several open-source mods in the CS1 modding community provided key API signatures that this project referenced:
- [CitiesSkylinesMultiplayer/CSM](https://github.com/CitiesSkylinesMultiplayer/CSM) ——
`EconomyManager.TakeNewLoan(index, amount, interest, length).MoveNext()` coroutine pattern;
reflection read of private `m_loans[]` field.
- [ZenyaIse/Cities-Skyline-Difficulty-Tuning-Mod](https://github.com/ZenyaIse/Cities-Skyline-Difficulty-Tuning-Mod) ——
structure of `EconomyManager.m_properties.m_banks[i].m_loanOffers[0] : LoanInfo`
( `m_amount` / `m_interest` / `m_length`).
- [Saladressing/CitiesSkylines-AutoSave](https://github.com/Saladressing/CitiesSkylines-AutoSave) ——
savegame trigger path using `SerializableDataExtensionBase.serializableDataManager.SaveGame(name)`.
## License
[MIT](LICENSE). This project is unofficial. Cities: Skylines is a trademark of Paradox Interactive / Colossal Order. Game DLLs referenced during build (`ICities.dll`, `ColossalManaged.dll`, `Assembly-CSharp.dll`, `UnityEngine.dll`) are not included in this repository and are not covered by this license; you must have your own copy of CS1 to run this
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
everything-claude-code
Complete Claude Code configuration collection - agents, skills, hooks,...
markitdown
MarkItDown-MCP is a lightweight server for converting URIs to Markdown.
cc-switch
All-in-One Assistant for Claude Code, Codex & Gemini CLI across platforms.
servers
Model Context Protocol Servers
servers
Model Context Protocol Servers
Time
A Model Context Protocol server for time and timezone conversions.