Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .devcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ npm run unity:mcp:host # bridges `unity mcp` on http://0.0.0.0:9020/
unity mcp configure <client>
```

Container agents connect to `http://host.docker.internal:9020/mcp` (configured automatically by `npm run mcp:sync`). Full details: `.llm/mcp/README.md`.
Container agents connect to `http://host.docker.internal:<port>/mcp` (default 9020, configured automatically by `npm run mcp:sync`). Automatic port forwarding, restoration, and
forwarding on URL open are disabled: these create host-to-container tunnels, while Unity needs container-to-host access. A tunnel on the bridge port can shadow the Windows host
listener and return no HTTP bytes. If an older session still forwards a bridge port, stop it in VS Code's Ports view, then reload the window. Explicit forwarding for container
services remains available in that view. Full details: `.llm/mcp/README.md`.

## Host networking note (Linux)

Expand Down
6 changes: 6 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@
"sst-dev.opencode"
],
"settings": {
// Unity runs on the host. Reverse tunnels can shadow its
// wildcard bridge listener with a broken loopback listener.
// Keep deliberate forwarding available through the Ports view.
"remote.autoForwardPorts": false,
"remote.restoreForwardedPorts": false,
"remote.forwardOnOpen": false,
// ============================================================
// Appearance
// ============================================================
Expand Down
9 changes: 4 additions & 5 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ CONTEXT7_API_KEY=

# ---------------------------------------------------------------------------
# Unity MCP bridge — shared token between Unity editors, host tooling, and
# devcontainer agents. `npm run mcp:sync` auto-generates a secure value if
# missing; keep host and container copies identical (same .env.local when the
# package folder is shared).
# devcontainer agents. Optional: set before starting the bridge, then keep
# host and container copies identical (same .env.local when shared). Sync
# preserves this choice and does not create or rotate the token.
# ---------------------------------------------------------------------------
UNITY_MCP_TOKEN=

Expand All @@ -63,8 +63,7 @@ UNITY_MCP_TOKEN=
# UNITY_PROJECT_PATH=C:\Path\To\UnityProject

# Optional overrides (sensible defaults apply):
# UNITY_MCP_TCP_HOST=host.docker.internal # how container agents reach host editors
# UNITY_MCP_BIND=loopback # or `all` (requires UNITY_MCP_TOKEN)
# UNITY_MCP_HTTP_URL=http://host.docker.internal:9020/mcp # endpoint override read by sync and both doctors
# UNITY_MCP_HTTP_PORT=9020 # npm run unity:mcp:host HTTP bridge port; when busy the bridge picks the next free port and persists the choice back to this file
# UNITY_MCP_TIMEOUT_MS=30000 # editor command timeout

Expand Down
3 changes: 2 additions & 1 deletion .llm/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,5 @@ runs everything, including the Prettier Markdown check, on ubuntu and windows. C
gitignored and must never be committed.
- Secrets live in gitignored `.env.local` (template: `.env.local.example`); config files reference env vars instead of embedding credentials.
- Unity MCP: the **official Unity CLI** MCP server (`unity mcp`, ~140 tools). Container agents reach it through the host HTTP bridge (`npm run unity:mcp:host` →
`host.docker.internal:<port>`, default 9020 via `UNITY_MCP_HTTP_PORT`); host GUI clients use `unity mcp configure <client>` directly. See [mcp/README.md](./mcp/README.md).
`host.docker.internal:<port>`, default 9020 via `UNITY_MCP_HTTP_PORT`); all six managed frontends share this bridge. Unmanaged host clients can use `unity mcp configure <client>`
directly. See [mcp/README.md](./mcp/README.md).
27 changes: 20 additions & 7 deletions .llm/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ first-run approval prompt. The isolated Claude state directories used by the `cl
the same trust + approvals by `python3 .devcontainer/ai_backends.py install` (and on every launcher start), because Claude only reads that state from the config dir its
`CLAUDE_CONFIG_DIR` points at.

Windsurf and other host GUI clients are not writable from the container; run the official configurator on the host instead (see below).
Unmanaged Windsurf and other host GUI clients are not writable from the container; run the official configurator on the host instead (see below).

`fetch`/`git` are only registered when `uvx` is on PATH (baked into the devcontainer image). Cursor's project config only carries host-executable entries (it omits `zai-image`,
whose script path is container-local). Merging preserves servers configured by other tools. The Z.ai credential is read from `ZAI_API_KEY` or `Z_AI_API_KEY`; `npm run mcp:sync`
mirrors whichever exists into the other inside `.env.local`.
`fetch`/`git` are only registered when `uvx` is on PATH (baked into the devcontainer image). Cursor's project config uses the same pinned Unity HTTP bridge and only carries
host-executable stdio entries (it omits `zai-image`, whose script path is container-local). Merging preserves servers configured by other tools. The Z.ai credential is read from
`ZAI_API_KEY` or `Z_AI_API_KEY`; `npm run mcp:sync` mirrors whichever exists into the other inside `.env.local`.

## Unity MCP server (official Unity CLI)

Expand All @@ -101,7 +101,7 @@ materials, animation, packages, tests, builds, plus an arbitrary C# `eval` tool)
# 1. Install the pipeline package into the Unity project
cd /path/to/UnityProject && unity pipeline install

# 2. Point host-side GUI clients (Cursor, Windsurf, Claude Desktop, …) at it
# 2. For unmanaged host GUI clients (Windsurf, Claude Desktop, …) only
unity mcp configure <client> # `unity mcp configure --list` for all
```

Expand All @@ -115,16 +115,23 @@ npm run unity:mcp:host # host: spawns `unity mcp`, serves http://0.0.0.0:9020/
```

Container agents connect to `http://host.docker.internal:9020/mcp` (written automatically by `npm run mcp:sync`). The bridge is transparent: JSON-RPC requests are forwarded to the
CLI with per-request id remapping, notifications are passed through, and a crashed CLI is respawned lazily on the next request.
CLI with per-request id remapping, client notifications are passed through, and a crashed CLI is respawned lazily on the next request. Startup also verifies a proxy-free local HTTP
roundtrip against a unique bridge instance ID before updating client configs. `/healthz` includes `bridgePid` and `instanceId`; `pid` identifies the lazy Unity CLI child. A failed
self-check exits with code 1. Port persistence uses asynchronous file I/O so it cannot block HTTP handling.

Environment (prefer a gitignored `.env.local` at the workspace root):
Environment (process variables override the gitignored workspace `.env.local`):

Keep the bridge URL, port, and token in the workspace `.env.local` so host and container clients read the same settings. The legacy host file `~/.unity-mcp/.env.local` is only a
fallback for CLI launch settings (`UNITY_CLI`, `UNITY_MCP_PROJECT`, `UNITY_PROJECT_PATH`, `UNITY_MCP_CLI_ARGS`); workspace values take precedence. Home-only transport settings are
ignored because frontend sync cannot read a Windows host home from the container.

| Variable | Purpose |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNITY_CLI` | Unity CLI binary path (default `unity`) |
| `UNITY_MCP_CLI_ARGS` | Extra args appended after `mcp` (e.g. `--instance host:port`) |
| `UNITY_MCP_PROJECT` | `--project-path` value; must be the project **root** (`UNITY_PROJECT_PATH` alias honored; non-root values self-heal to the enclosing project) |
| `UNITY_MCP_HTTP_PORT` | Bridge port (default 9020). When busy, the bridge binds the next free port, persists the choice here, and re-syncs client configs (`UNITY_MCP_AUTO_SYNC=0` disables) |
| `UNITY_MCP_HTTP_URL` | Endpoint override read from process env or `.env.local` by sync and both doctors |
| `UNITY_MCP_TOKEN` | Optional bearer token required by the bridge when set |
| `UNITY_MCP_TIMEOUT_MS` | Per-request timeout (default 180000) |

Expand Down Expand Up @@ -160,3 +167,9 @@ Security: the bridge listens on all interfaces by default (Linux Docker cannot r

The previous hand-rolled `UnityMcpBridge.cs` (10 tools) is deprecated. If it was installed into the Unity project, delete `<project>/Assets/Editor/UnityMcpBridge.cs` (and its
`.meta`) — it is no longer installed or served by anything in this repo.

### Diagnose connection failures

Run `npm run mcp:sync`, restart the host bridge and agent sessions, then run `npm run unity:mcp:doctor` from the container. The doctor checks initialization, tool discovery, and
the pinned editor. If `/healthz` times out, check the host URL and remove forwarded Unity bridge ports in VS Code's Ports view: reverse tunnels can intercept host traffic. Connect
directly to the host bridge. Match health's `bridgePid` to the listener owner; health bypasses authentication and does not start Unity.
68 changes: 32 additions & 36 deletions .llm/mcp/configure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@
// Unity uses the official Unity CLI MCP server (`unity mcp`, ~140 tools on a
// typical project). Container frontends reach it through the host HTTP bridge
// (npm run unity:mcp:host → http://host.docker.internal:<port>/mcp, port from
// UNITY_MCP_HTTP_PORT); host-side GUI clients (Cursor/Windsurf/Claude Desktop)
// should use the CLI directly via `unity mcp configure <client>` on the host.
// UNITY_MCP_HTTP_PORT). Every managed frontend shares the pinned bridge;
// unmanaged host GUI clients can use `unity mcp configure <client>`.

import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -97,22 +96,10 @@ function hasCommand(command) {
}

function ensureUnityMcpToken() {
const existing = env("UNITY_MCP_TOKEN");
if (existing) return existing;
if (CHECK_ONLY) return "(generated)";
const token = crypto.randomBytes(24).toString("hex");
const envLocalPath = path.join(WORKSPACE_ROOT, ".env.local");
let content = "";
try {
content = fs.readFileSync(envLocalPath, "utf8");
} catch {
content = "# Machine-local credentials (gitignored).\n";
}
if (content.length > 0 && !content.endsWith("\n")) content += "\n";
content += `\n# Auto-generated by configure.mjs on ${new Date().toISOString()}\nUNITY_MCP_TOKEN=${token}\n`;
fs.mkdirSync(path.dirname(envLocalPath), { recursive: true });
fs.writeFileSync(envLocalPath, content, { mode: 0o600 });
return token;
// Authentication is a host choice. Sync must not introduce a token after
// the bridge has already started without one, or require an absent env
// variable in clients that reject missing bearer_token_env_var values.
return env("UNITY_MCP_TOKEN");
}

// The Z.ai credential circulates under two names: the MCP configs and the
Expand Down Expand Up @@ -195,7 +182,7 @@ const UNITY_MCP_PORT = Number(env("UNITY_MCP_HTTP_PORT")) || 9020;
// container agents — so it is never the default. Override the whole URL with
// UNITY_MCP_HTTP_URL for exotic topologies.
const UNITY_MCP_URL =
process.env.UNITY_MCP_HTTP_URL ||
env("UNITY_MCP_HTTP_URL") ||
`http://host.docker.internal:${UNITY_MCP_PORT}/mcp`;

// VS Code spawns MCP servers from the extension host, whose environment is
Expand All @@ -211,51 +198,55 @@ function vscodeEnvWrapper(command) {
return ["bash", "-c", `${VSCODE_ENV_WRAPPER_PREFIX}; exec ${command}`];
}

function shellQuote(value) {
return "'" + value.replaceAll("'", "'\\''") + "'";
}

function buildCatalog() {
const servers = new Map();
const unityAuth = Boolean(env("UNITY_MCP_TOKEN"));
servers.set("unity", {
summary: `Unity official CLI MCP (unity mcp, ~140 tools) via HTTP bridge ${UNITY_MCP_URL}`,
claude: {
type: "http",
url: UNITY_MCP_URL,
headers: {
...(unityAuth && { headers: {
Authorization: "Bearer ${UNITY_MCP_TOKEN:-}",
},
} }),
},
codex: {
url: UNITY_MCP_URL,
bearer_token_env_var: "UNITY_MCP_TOKEN",
...(unityAuth && { bearer_token_env_var: "UNITY_MCP_TOKEN" }),
},
opencode: {
type: "remote",
url: UNITY_MCP_URL,
enabled: true,
oauth: false,
timeout: 300000,
headers: {
...(unityAuth && { headers: {
Authorization: "Bearer {env:UNITY_MCP_TOKEN}",
},
} }),
},
nanocoder: {
transport: "http",
url: UNITY_MCP_URL,
headers: {
...(unityAuth && { headers: {
Authorization: "Bearer ${UNITY_MCP_TOKEN:-}",
},
} }),
},
vscode: {
// stdio wrapper (mcp-remote) instead of native remote HTTP: the
// extension host's frozen environment cannot provide
// ${env:UNITY_MCP_TOKEN}, but the wrapper sources .env.local.
command: vscodeEnvWrapper(
`npx -y mcp-remote ${UNITY_MCP_URL} --header "Authorization:\${UNITY_MCP_TOKEN}"`,
`npx -y mcp-remote ${shellQuote(UNITY_MCP_URL)}${unityAuth ? ' --header "Authorization: Bearer ${UNITY_MCP_TOKEN}"' : ''}`,
),
},
// Cursor runs on the host, so it can launch the CLI directly (stdio)
// without going through the HTTP bridge. `unity mcp configure cursor`
// on the host rewrites this with the absolute CLI path if `unity` is
// not on the host PATH.
cursor: { command: "unity", args: ["mcp"] },
cursor: {
url: UNITY_MCP_URL,
...(unityAuth && { headers: { Authorization: "Bearer ${env:UNITY_MCP_TOKEN}" } }),
},
});
servers.set("github", {
summary: "GitHub platform (repos, PRs, issues, actions, security…) via hosted MCP, all toolsets",
Expand Down Expand Up @@ -788,7 +779,12 @@ function main() {
managedCodex[name] = definition.codex;
managedOpencode[name] = opencodeServerV2(definition.opencode);
managedNanocoder[name] = definition.nanocoder;
managedVscode[name] = definition.vscode;
// VS Code requires a command string plus args. OpenCode's command
// array shape is not a valid VS Code stdio server definition.
const vscode = definition.vscode;
managedVscode[name] = Array.isArray(vscode.command)
? { ...vscode, command: vscode.command[0], args: vscode.command.slice(1) }
: vscode;
if (definition.cursor) managedCursor[name] = definition.cursor;
}

Expand Down Expand Up @@ -921,7 +917,7 @@ function main() {
console.log(" (fetch/git skipped: uvx not found on PATH)");
}
console.log(
` UNITY_MCP_TOKEN: ${unityToken && unityToken !== "(generated)" ? "configured" : "OPEN (no token)"}`,
` UNITY_MCP_TOKEN: ${unityToken ? "configured" : "OPEN (no token)"}`,
);
const describeZai = (status) => {
if (status === "missing") return "MISSING (zai-* servers will fail auth)";
Expand Down Expand Up @@ -953,7 +949,7 @@ function main() {
` Unity MCP: official Unity CLI (~140 tools) via ${UNITY_MCP_URL} — start the bridge with \`npm run unity:mcp:host\` on the host.`,
);
console.log(
" Host GUI clients (Cursor/Windsurf/Claude Desktop): run `unity mcp configure <client>` on the host for native stdio.",
" Managed Cursor shares the bridge; unmanaged host clients can run `unity mcp configure <client>` for native stdio.",
);
console.log("");
console.log(" Restart your agent CLIs (or run /mcp) to pick up changes.");
Expand Down
Loading
Loading