headless-unity-mcp
# headless-unity-mcp
drive a unity project's build/test/screenshot loop from an MCP client — in batchmode, with
no interactive editor open, ever.
six tools: `unity_compile`, `unity_test`, `unity_scene`, `unity_build`, `unity_shot`,
`unity_targets`, plus `unity_status`. each returns a compact structured verdict, not a wall
of unity log:
```json
{"ok": true, "verdict": "OK EditMode total=234 passed=234 failed=0",
"totals": [{"platform": "EditMode", "total": 234, "passed": 234, "failed": 0,
"results_xml": "/path/Logs/editmode-results.xml"}]}
```
macOS only, for now. see [limitations](#limitations).
---
## the problem
if you point an agent at a unity project, three things go wrong.
**unity's exit codes lie.** the editor exits `0` with `error CS0103` sitting in the log. it
exits nonzero when everything compiled. it writes no test results XML at all and still
exits `0`. an agent that trusts `$?` will tell you the build is green when it isn't, and
that's the single worst failure an agent can have — not being wrong, but being *confidently*
wrong.
**batchmode output is a context bomb.** a single `-batchmode` compile emits tens of
thousands of lines even with `-logFile` set. a player screenshot is a PNG that costs
thousands of tokens to look at. dump either into an agent's context and you've spent the
budget you needed for the actual work. the usual workaround — wrap every call in a
throwaway subagent that summarizes — is real overhead you shouldn't have to pay.
**the existing unity MCP servers need a live editor.** near all of them (coplaydev,
codergamester, ivanmurzak, anklebreaker, unity's own first-party package) are
*editor-embedded bridges*: a C# package that opens a socket inside a **running, interactive
unity editor**. that's the architecture, not a gap in the docs. it means you cannot run
headless, you cannot run in CI, and you inherit the domain-reload problem — recompile your
scripts and the bridge's C# state is torn down underneath it, so the agent goes deaf
mid-task (coplaydev [#814](https://github.com/CoplayDev/unity-mcp/issues/814) — "agents
sleep after script changes").
## what this does instead
spawn-and-exit. every verb launches `Unity -batchmode -nographics`, waits for it to exit,
and parses the evidence it left on disk. there's no long-lived editor to go stale, so the
domain-reload bug class doesn't exist here — the process is gone before the next call
starts.
the verdict is derived from evidence, never from the exit code:
| what | the actual verdict |
|---|---|
| compile | a `error CS[0-9]+` grep of the unity log. exit code is ignored. |
| test | the NUnit results XML. **missing XML is a FAIL**, and any `result="Failed"` is a FAIL. |
| build | the `.app` exists on disk (and a `.app` is a *directory* — tested with `-d`, not `-f`). |
| shot | a PNG over 10KB captured from the player's real window, by `CGWindowID`. |
| fatal abort | `Aborting batchmode due to fatal error` never reaches `-logFile` — it goes to the process's **stdout**. we read that too, and surface it as an `abort:` line instead of a truncated log and a mystery exit code. |
## why this over the alternatives
| | editor-embedded bridges | **headless-unity-mcp** |
|---|---|---|
| needs an interactive editor open | yes | **no** |
| survives a script recompile mid-task | no (domain reload tears down the bridge) | **n/a — nothing is long-lived** |
| usable in CI / over ssh / on a headless box | no | **yes** |
| "run tests" means | the live test-runner API | **`-runTests` + the results XML as evidence** |
| trusts unity's exit code | typically yes | **never** |
| screenshots | the *editor* window (or unsupported on macOS) | **the built player's window** |
| concurrent calls | undefined | **machine-wide lock, one cycle at a time** |
| project-specific setup | a C# package in your project | **one toml file** |
the honest counterpoint: an embedded bridge can do things a batchmode process fundamentally
can't — inspect a live scene graph, read the editor console in real time, poke a
`GameObject` while the game is running, offer 40+ fine-grained tools. if that's what you
need, use one of those. this server does five verbs and does them **verifiably**.
## requirements
- **macOS.** the screenshot path is Quartz + `screencapture`, and the build targets a mac
standalone player. the compile/test/build verbs are portable in principle; nothing else
has been ported yet.
- **unity** — any version, installed via the hub (or point `[unity] path` anywhere).
- **python 3.11+** (needs `tomllib`).
- for `unity_shot`: `pyobjc-framework-Quartz`, plus the **Screen Recording** grant for
whichever app hosts your shell. macOS gates *all* window capture behind it; without the
grant every capture fails with `could not create image from window`. the driver preflights
this and refuses with the fix rather than writing you a black PNG.
## install
```sh
git clone https://github.com/Shonas301/headless-unity-mcp
cd headless-unity-mcp
python3 -m venv .venv
.venv/bin/pip install -e ".[shot]" # drop [shot] if you don't need screenshots
```
## configure
drop a `unity-mcp.toml` in the **root of your unity project** (next to `Assets/` and
`ProjectSettings/`). this is the only project-specific file — the server and the driver stay
generic.
```toml
default_target = "mac"
[unity]
version = "6000.5.0f1" # validated against ProjectSettings/ProjectVersion.txt
# path = "/Applications/Unity/Hub/Editor/6000.5.0f1/Unity.app/Contents/MacOS/Unity"
[shot]
delay_s = 9 # wait out the unity splash; the splash window is NOT the game window
width = 800
height = 600
[targets.mac]
build_method = "MyGame.Editor.BuildScript.BuildMacDev" # a public static method, via -executeMethod
app_path = "Build/Mac/MyGame.app"
# optional — only if your project authors scenes from code
scene_method = "MyGame.Editor.SceneBuilder.Generate"
scene_path = "Assets/Scenes/Main.unity"
```
`build_method` is an ordinary editor static method, the same one you'd call from CI:
```csharp
public static class BuildScript {
public static void BuildMacDev() { BuildPipeline.BuildPlayer(/* ... */); }
}
```
that `[unity] version` check is load-bearing. it's what stops a call aimed at the wrong
project from cheerfully reporting all-green — a real failure mode, and one that is very hard
to notice, because every individual line of the output looks fine.
see [`unity-mcp.example.toml`](unity-mcp.example.toml) for the annotated version.
## register with an MCP client
`.mcp.json` (claude code, and most clients take the same shape):
```json
{
"mcpServers": {
"unity": {
"command": "/abs/path/to/headless-unity-mcp/.venv/bin/python",
"args": ["-m", "unity_mcp.server"],
"timeout": 1800000
}
}
}
```
the long `timeout` matters: a build or a PlayMode run takes minutes, and a queued call
blocks until the machine lock frees. progress notifications do **not** extend an MCP
client's tool timeout, so the timeout has to actually cover the wait.
every tool takes an optional `workspace` (absolute path to the unity project). omit it and
the server uses its own cwd, which is what you want when the client launches one server per
project.
## the tools
| tool | what it does |
|---|---|
| `unity_compile` | import + compile all assemblies. verdict is a log grep, never `$?`. |
| `unity_test` | `platform: edit \| play \| all`. returns parsed per-platform totals. missing XML = FAIL. |
| `unity_scene` | regenerate a target's scene via its `scene_method`. |
| `unity_build` | build a target's standalone player via its `build_method`. |
| `unity_shot` | launch the built player, screenshot its window, return the PNG **path**. |
| `unity_targets` | list the targets this project defines. never takes the lock. |
| `unity_status` | who holds the machine lock right now. never takes the lock. |
### one build at a time, machine-wide
there is one unity binary and one license on your machine, and two editors **cannot open the
same project at all** — the second one dies with `Multiple Unity instances cannot open the
same project`. so the server serializes.
it has to be a real OS lock, not an in-process one: each MCP client spawns its **own** stdio
server process, so an `asyncio.Lock` would serialize nothing across them. this uses
`fcntl.flock` on a file under `~/.cache/headless-unity-mcp/`, machine-wide. a second caller
**blocks** until the first finishes (bounded by `queue_wait_timeout_s`, default 900s), then
runs. if it gives up, the refusal names who held the lock and since when.
`unity_status` reads the holder without taking the lock, so you can always ask.
### about `unity_shot`
it returns the PNG's **path**, not the image. that's deliberate, but it means the one context
bomb this server can't defuse is still live: deciding whether a screenshot shows a real
gameplay frame or the unity splash requires *looking at pixels*, and that's thousands of
tokens whoever does it.
so: **read the PNG in a throwaway subagent**, not in your main context. ask it for one
sentence. the other five verbs return one-line verdicts and need no such thing.
## standalone / CI
the driver is a plain shell script and works with no MCP client at all:
```sh
UNITY_MCP_PROJECT=/path/to/project ./src/unity_mcp/driver.sh compile
UNITY_MCP_PROJECT=/path/to/project ./src/unity_mcp/driver.sh test edit
UNITY_MCP_PROJECT=/path/to/project ./src/unity_mcp/driver.sh build mac
```
it prints the same verdict lines the tools parse, and exits nonzero on failure. the MCP
server shells out to exactly this file — a verdict you see through a tool is a verdict this
script printed. there is no second implementation to drift.
## verifying it
the unit suite is hermetic (a stub driver fakes unity's failure modes; no unity needed):
```sh
.venv/bin/python -m pytest -q
.venv/bin/python -m ruff check .
.venv/bin/python -m mypy
```
the **gates** are the real harness — they assert the properties that actually matter, and
they write machine-readable evidence to `gates/evidence/`:
```sh
.venv/bin/python gates/run_gates.py # G0–G4, hermetic
UNITY_MCP_GATE_WORKSPACE=/path/to/project \
UNITY_MCP_GATE_EDIT_FLOOR=230 \
.venv/bin/python gates/run_gates.py # + G5/G6 against real unity
```
| gate | asserts |
|---|---|
| G0 | ruff + mypy + unit suite clean |
| G1 | two concurrent calls to one server don't overlap |
| G2 | two concurrent calls to **separate server processes** don't overlap (the test an `asyncio.Lock` fails) |
| G3 | two workspaces, two processes, neutral cwd — each reports **its own** numbers (arithmetic tripwire: A must say 7, B must say 13) |
| G4 | guard parity: exit-code-lies, fatal abort, missing XML, bad project and bad target both refused *before* the lock, and a timeout kills the whole **process group** (no orphaned 2GB editor) |
| G5 | real unity: compile + EditMode against a real project, with a totals floor |
| G6 | real screenshot: a >10KB PNG off the built player |
`SKIPPED` always carries a reason and is never counted as a pass.
## limitations
- **macOS only.** the shot path is Quartz-specific and the build targets a mac player.
- **no live-editor introspection.** no scene graph, no live console, no poking a running
game. that's the trade for not needing an editor.
- **you cannot inject input into the player.** a screenshot shows you a frame; it can't
press a key. drive gameplay from PlayMode tests instead — give your controller an input
seam (a `Func<Vector2>` the test can set) and assert on real physics.
- **one project at a time per machine**, by construction. that's the lock, and it's a
feature.
## license
MIT.
TDQS
Scored across 7 tools
Each tool maps to a distinct Unity operation: screenshot, lock status, build, list targets, compile, test, and scene generation. Build vs compile are clearly differentiated by their descriptions, and no two tools appear to serve the same purpose.
All tools share a consistent 'unity_' prefix, but the action words mix verbs (build, compile, test) with nouns (shot, status, targets, scene). This is mostly predictable but deviates from a pure verb_noun pattern.
With 7 tools, the server is well-scoped for a Unity CI/headless workflow. Each tool covers a specific need without redundancy, and the count is well within the ideal 3-15 range.
The tool surface covers the core lifecycle: build, compile, test, screenshot, and scene generation, plus target listing and lock status. Minor gaps like explicit lock release or raw log retrieval are workarounds, but the main workflows are complete.