Architecture¶
worldbox-mcp is split across three address spaces that communicate through two well-defined boundaries.
┌─────────────┐ MCP ┌──────────────────┐ HTTP loopback ┌──────────────────────┐
│ AI client ├────────────►│ worldbox-mcp ├─────────────────►│ WorldBoxBridge │
│ (any) │ stdio/HTTP │ (Python, PyPI) │ 127.0.0.1:8723 │ BepInEx C# plugin │
└─────────────┘ └──────────────────┘ │ inside worldbox.exe │
│ │
│ ┌────────────────┐ │
│ │ TCP listener │ │
│ │ + hand-rolled │ │
│ │ HTTP/1.1 │ │
│ │ Auth + routing │ │
│ └────────┬───────┘ │
│ │ │
│ Session layer │
│ (agents, perms, │
│ message bus, │
│ turn order) │
│ │ │
│ ┌────────▼───────┐ │
│ │ Main thread │ │
│ │ dispatcher │ │
│ │ (PlayerLoop) │ │
│ └────────┬───────┘ │
│ │ │
│ ┌────────▼───────┐ │
│ │ Command via │ │
│ │ reflection on │ │
│ │ Assembly-CSharp│ │
│ └────────────────┘ │
└──────────────────────┘
Where things live¶
worldbox-mcp/
├── mod/ BepInEx 5 plugin, net462, injected into the game process
│ ├── src/WorldBoxBridge/
│ │ ├── Plugin.cs entry point, wires everything together
│ │ ├── Http/ TcpListener, HTTP/1.1 parser, auth, error envelope, turn gating
│ │ ├── Commands/ one directory per category, one file per command
│ │ ├── Reflection/ cached, fail-soft access to game internals
│ │ ├── Session/ agents, permissions, fog of war, turn order, message bus
│ │ └── Threading/ main-thread dispatcher injected into Unity's PlayerLoop
│ └── tests/ xUnit, net8, linked sources so it builds without Unity
├── server/ Python MCP server, published to PyPI
│ └── src/worldbox_mcp/
│ ├── server.py server factory
│ ├── client.py httpx wrapper around the bridge
│ └── tools/ one module per command category
├── docs/ this site
├── examples/ client configs, demo prompts, runnable scenarios
└── scripts/ installers, dev bootstrap, and the docs generator
| Entry point | File | Triggered by |
|---|---|---|
| Plugin load | mod/src/WorldBoxBridge/Plugin.cs, Awake |
BepInEx chainloader at game start |
| HTTP request | mod/src/WorldBoxBridge/Http/HttpBridge.cs |
any call to 127.0.0.1:8723 |
| Introspection | same file, GET /capabilities |
served directly, not through a command |
| Server CLI | server/src/worldbox_mcp/__main__.py, main |
uvx worldbox-mcp |
Counting the tool surface¶
29 MCP tools, which is
28 registered
ICommand implementations plus the /capabilities endpoint. File count and command count
differ because PauseCommand.cs declares two commands, pause and resume.
Both numbers above are generated. scripts/gen-docs.py reads them from the registered tools
and from the C# sources, rewrites them wherever they are stated, and fails CI when a count or
an inventory table drifts. Never edit one by hand, see development.md.
Versions that are pinned on purpose¶
| Package | Pinned in | Reason |
|---|---|---|
UnityEngine.Modules |
mod/Directory.Packages.props |
Must match the engine the game ships. Comes from the BepInEx feed, mapped by exact id in mod/NuGet.config because nuget.org's copy stops at 2021.3 |
Newtonsoft.Json |
same | The game's bundled copy wins at runtime, so a newer nuget throws MissingMethodException |
Microsoft.NETFramework.ReferenceAssemblies |
same | Referenced explicitly so the restore graph is identical on Windows, Linux and macOS |
BepInEx.Core |
same | Manual bumps only. Pulls HarmonyX transitively at the version the game expects |
| csharpier | .github/workflows/ci.yml |
1.x changed the CLI and the XML formatting defaults |
packages.lock.json is committed for both mod projects, so dotnet restore --locked-mode really
does pin content hashes. See development.md for how to regenerate it.
Why this layout¶
| Boundary | Why a separate process |
|---|---|
| AI client ↔ MCP server | The MCP spec dictates this; lets any client reuse the same server. |
| MCP server ↔ Mod | The mod must live inside worldbox.exe to access game internals. The MCP server stays a normal Python process, easy to ship via PyPI, runs on any OS, no Unity baggage. |
Component responsibilities¶
worldbox-mcp (Python server)¶
- Speaks the MCP wire protocol (stdio + Streamable HTTP).
- Exposes a curated tool surface to AI clients (see command-reference.md).
- Owns the contract: input validation via Pydantic, error mapping, retries on transient HTTP failures.
- Auto-discovers the mod's auth token by reading
<worldbox>/BepInEx/config/WorldBoxBridge.cfg. - Does not know anything about WorldBox internals. It is a thin, typed façade over the HTTP bridge.
WorldBoxBridge (BepInEx C# plugin)¶
- Hosts an HTTP/1.1 server built on
System.Net.Sockets.TcpListener+ a hand-rolled request parser, bound to127.0.0.1. Authenticated with a bearer token (one shared secret in legacy single-tenant mode; one per agent in multi-agent mode). We useTcpListenerrather thanSystem.Net.HttpListenerbecause the latter silently fails to bind under Unity 2022.3 Mono, see gotcha 1 in game-api-notes.md. - Holds a session layer (v0.3+) on top of HTTP routing: agent registry (token → role
/ faction / permissions), in-memory message bus with per-agent inboxes, optional
turn-order. Loaded from
BepInEx/config/WorldBoxBridge.agents.jsonat startup; falls back to legacy single-token mode if the file is absent. See multi-agent.md. - Dispatches incoming JSON commands onto Unity's main thread via a
ConcurrentQueue<Action>drained from a delegate injected into Unity'sPlayerLoop(not aMonoBehaviour). On WorldBox 0.51.2, BepInEx-createdMonoBehaviourGameObjects get destroyed shortly after Awake, the PlayerLoop hook is part of the engine's tick table and survives that. - Resolves all WorldBox types via cached reflection (never
using WorldBox.*directly) so the mod survives game updates as long as core types keep their names. - Maps every command to game APIs that live inside
Assembly-CSharp.dll.
Critical invariants¶
- Unity API calls happen on the main thread. Period. The dispatcher is the only legal way for HTTP handlers to touch the game.
- Auth is checked before any work. The HTTP middleware short-circuits on a bad token before queueing anything onto the main thread.
- Loopback only.
HttpListenerbound to127.0.0.1. Refused at startup if config tries0.0.0.0. - No static binding to game types. A reflection lookup that fails logs a warning and disables only the affected command, the rest of the bridge keeps working.
Data flow for a tool call¶
- AI client emits
tools/callover MCP. - Python server validates args with Pydantic, builds a JSON command envelope, sends
POST /cmdwithAuthorization: Bearer <token>(the legacyX-WB-Tokenheader is still accepted). - Mod's HTTP handler verifies the bearer against the
AgentRegistry, resolves it into aRequestContext(agent id, role, kingdom claim, permissions, scenario flags), then parses the JSON body. - The bridge takes an admission slot, waiting up to five seconds for one and answering
503 BUSYif none frees up. The cap ismax_concurrent_requestsinWorldBoxBridge.cfg, default 8. It sits after authentication so unauthenticated traffic cannot spend the slots, and before everything below because past this point a request can hold a whole save in memory. - The bridge runs the per-command permission gate (
ctx.Require(Permission.X)) and (in turn-based sessions) checks that the caller holds the current turn. Both fail fast before any game-state work. - Bridge enqueues an
Actionon the main-thread dispatcher with aTaskCompletionSource. A command that reportsRequiresMainThread == falseskips this step and runs on the pool thread, marshalling only the calls that need a frame.load_worldis the case to copy: it resolves the path and reads the file on the pool thread and queues nothing but theloadMapFromBytescall. - Next Unity frame: dispatcher pops the action, the command runs with
RequestContextin scope, so it knows who is calling and can fog-of-war-filter what it returns, and sets the TCS result. It cannot scope a write to the caller's kingdom: nothing in the bridge does, see multi-agent.md. - HTTP handler awaits the TCS. What the dispatched call returns is the command's task, which the handler then awaits off the main thread, under a 60-second backstop, so a command that only finishes on a later frame (
invoke_powerwithpulses) does not deadlock the frame it started on. It serializes the result and returns200 OK, releasing the admission slot on the way out. - Python server returns the result to the MCP client.
The dispatcher's 30-second deadline is a queueing deadline, not a watchdog. MainThreadDispatcher.Tick compares it against the clock just before it calls an action, so an action that waited too long for a frame is dropped with MAIN_THREAD_TIMEOUT, and an action that has started runs to completion whatever it does. Nothing on the main thread can be interrupted. That is why blocking I/O must never be queued: see the RequiresMainThread remarks on LoadWorldCommand, and gotcha 11 in game-api-notes.md.
Two counted bounds keep a burst of callers from turning into unbounded work, and they are deliberately separate. The admission gate in HttpBridge.ExecuteCommandAsync caps how many commands execute at once, which is what stops N parallel load_world calls from allocating N saves. The dispatcher caps registered per-frame jobs at 32, the same number of queued actions it drains per frame, which is what stops N invoke_power runs with pulses from costing N delegate invocations every frame for 25 seconds each. The second is not merely a consequence of the first: the handler's 60-second backstop can abandon a job that outlives it, and a bound that holds only while two constants in different files keep their current relationship is not a bound. Both refusals surface as 503 BUSY. ConcurrencyGate is the shared primitive, and it is free of Unity and BepInEx types so the test project links it directly.
That two-await shape has a consequence worth stating before someone hits it: RequiresMainThread == true puts the first thread of the command on the main thread, not the whole method. An await inside such a command does not land where you might guess. Unity installs UnityEngine.UnitySynchronizationContext on the main thread, it is in the UnityEngine.Modules reference assembly the mod compiles against, and the engine pumps it from the player loop, so the continuation returns to the main thread rather than to a pool thread. What it escapes is the dispatcher: no queueing deadline, no maxPerFrame bound, and no defined order against actions still queued. The trap is that ConfigureAwait(false) or Task.Run, reached for to get back onto the main thread, is what leaves it. Write a main-thread command as one synchronous body returning a task, and push anything that has to wait through MainThreadDispatcher.RunPerFrameOnMainThreadAsync. No command that reports true awaits today; load_world awaits and reports false, which is safe because it awaits from the pool thread. The remarks on ICommand.RequiresMainThread carry the same warning where an author will actually be reading.
Threading model summary¶
| Thread | Owns |
|---|---|
| .NET thread pool | HTTP socket I/O, JSON parsing, command queueing, and everything a command does that does not touch a Unity API, which for load_world includes resolving the path and reading the save |
| Unity main thread | All game state reads/writes, all MapBox/World/Actor access |
| Logger | Thread-safe via BepInEx.Logging.ManualLogSource |