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
2 changes: 1 addition & 1 deletion .llm/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ runs everything, including the Prettier Markdown check, on ubuntu and windows. C
- Gateway launchers (`codex-zai`, `claude-zai`, `codex-openrouter`, `claude-openrouter`) come from `.devcontainer/ai_backends.py`. They self-load the gitignored `.env.local` for
keys not already exported (preexisting environment variables win), so they work in non-interactive shells too. Python startup, backend, and service tools share
`.devcontainer/env_local.py`; the sourced Bash loader remains for parent-shell exports, with parity pinned by `scripts/tests/test_env_local_precedence.py`.
- `npm run mcp:sync` regenerates machine-local MCP configs for Claude Code, Codex, OpenCode, Nanocoder, VS Code, and Cursor from `.llm/mcp/configure.mjs`. Those outputs are
- `npm run mcp:sync` regenerates machine-local MCP configs for Claude Code, Codex, OpenCode, Nanocoder, VS Code, and Cursor through `scripts/mcp_configure.py`. Those outputs are
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` →
Expand Down
12 changes: 6 additions & 6 deletions .llm/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ npm run unity:mcp:host # host: spawns `unity mcp`, serves http://0.0.0.0:9020/
```

Startup settings resolve through `scripts/unity_mcp_settings.py` using uv or Python 3.9+. Project pins prefer process variables, then workspace settings, then the legacy home file,
regardless of which project-path alias each source uses. HTTP and CLI process handling remain in the Node bridge.
regardless of which project-path alias each source uses. HTTP and CLI process handling use `scripts/unity_mcp_host.py`.

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, client notifications are passed through, and a crashed CLI is respawned lazily on the next request. Startup also verifies a proxy-free local HTTP
Expand Down Expand Up @@ -148,12 +148,12 @@ Security: the bridge listens on all interfaces by default (Linux Docker cannot r
- **`unity run unity:mcp:host` fails with "Not a Unity project".** That invoked the Unity CLI, not the npm script: `unity run <project>` expects a project path, so it treats
`unity:mcp:host` as one. The bridge is an npm script — start it with `npm run unity:mcp:host`.
- **Port already in use / dynamic ports.** Re-running the bridge is a no-op while a healthy instance holds the port; if something else owns it, the bridge binds the next free port,
persists `UNITY_MCP_HTTP_PORT` to `.env.local`, and re-syncs every frontend config automatically. No fixed port is required anywhere: `configure.mjs`, the devcontainer health
persists `UNITY_MCP_HTTP_PORT` to `.env.local`, and re-syncs every frontend config automatically. No fixed port is required anywhere: `mcp_configure.py`, the devcontainer health
check, and the doctors all read the persisted value.
- **`npm run mcp:sync` on the host.** It runs the cross-platform Node adapter `configure.mjs`. Codex persistence and workspace cleanup use `scripts/mcp_codex_config.py`; JSON
- **`npm run mcp:sync` on the host.** It runs the typed Python driver `scripts/mcp_configure.py`. Codex persistence and workspace cleanup use `scripts/mcp_codex_config.py`; JSON
persistence uses `scripts/mcp_json_config.py`. Credential aliases use `scripts/mcp_aliases.py`, preserving BOM and line endings and emitting statuses only. The managed server
catalog uses `scripts/mcp_catalog.py`. These tools run through uv or Python 3.9+ when uv is absent. Container hooks use `sync_mcp.py`
(`uv run .llm/mcp/sync_mcp.py [workspace_dir] [flags...]`). Command discovery uses `where` on Windows.
catalog uses `scripts/mcp_catalog.py`. The npm commands use uv; standalone entry points also support Python 3.9+. Container hooks use `sync_mcp.py`
(`uv run .llm/mcp/sync_mcp.py [workspace_dir] [flags...]`). Command discovery follows platform PATH rules.
- **Shared configs are environment-agnostic.** Sync produces identical files from the host and the container (verified by hash): the unity bridge URL always uses
`host.docker.internal` (resolves on the host under Docker Desktop too), and zai-image uses the PATH-resolved `uv` runner with a workspace-relative script. Never reintroduce
`127.0.0.1` or absolute binaries into shared entries — they silently break the other environment.
Expand Down Expand Up @@ -182,4 +182,4 @@ the pinned editor. If `/healthz` times out, check the host URL and remove forwar
directly to the host bridge. Match health's `bridgePid` to the listener owner; health bypasses authentication and does not start Unity.

The npm commands `npm run mcp:sync` and `npm run unity:mcp:host` run typed Python through uv. Direct entry points are `uv run scripts/mcp_configure.py` and
`uv run scripts/unity_mcp_host.py`. Legacy Node paths remain small launchers with Python 3.9+ fallback; the Python sync wrapper does not require Node.
`uv run scripts/unity_mcp_host.py`. Legacy configuration and bridge Node paths remain small launchers with Python 3.9+ fallback; the Python sync wrapper does not require Node.
8 changes: 5 additions & 3 deletions .llm/mcp/configure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { fileURLToPath } from "node:url";
const script = fileURLToPath(new URL("../../scripts/mcp_configure.py", import.meta.url));
for (const [command, prefix, probe] of [
["uv", ["run", "--no-project"], ["--version"]],
["python3", [], ["-c", "import sys; assert sys.version_info >= (3, 9)"]],
["python", [], ["-c", "import sys; assert sys.version_info >= (3, 9)"]],
["python3", [], ["-c", "import sys; assert sys.version_info >= (3, 9); print('dataviz-python')"]],
["python", [], ["-c", "import sys; assert sys.version_info >= (3, 9); print('dataviz-python')"]],
]) {
const available = spawnSync(command, probe, { stdio: "ignore" });
const available = spawnSync(command, probe, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
if (available.status !== 0) continue;
const ready = command === "uv" ? available.stdout.startsWith("uv ") : available.stdout.trim() === "dataviz-python";
if (!ready) continue;
const result = spawnSync(command, [...prefix, script, ...process.argv.slice(2)], { stdio: "inherit" });
if (result.error) console.error("Cannot start MCP configuration: " + result.error.message);
process.exit(result.status ?? 1);
Expand Down
8 changes: 5 additions & 3 deletions .llm/mcp/unity-mcp-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { fileURLToPath } from "node:url";
const script = fileURLToPath(new URL("../../scripts/unity_mcp_host.py", import.meta.url));
for (const [command, prefix, probe] of [
["uv", ["run", "--no-project"], ["--version"]],
["python3", [], ["-c", "import sys; assert sys.version_info >= (3, 9)"]],
["python", [], ["-c", "import sys; assert sys.version_info >= (3, 9)"]],
["python3", [], ["-c", "import sys; assert sys.version_info >= (3, 9); print('dataviz-python')"]],
["python", [], ["-c", "import sys; assert sys.version_info >= (3, 9); print('dataviz-python')"]],
]) {
const available = spawnSync(command, probe, { stdio: "ignore" });
const available = spawnSync(command, probe, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
if (available.status !== 0) continue;
const ready = command === "uv" ? available.stdout.startsWith("uv ") : available.stdout.trim() === "dataviz-python";
if (!ready) continue;
const result = spawnSync(command, [...prefix, script, ...process.argv.slice(2)], { stdio: "inherit" });
if (result.error) console.error("Cannot start MCP bridge: " + result.error.message);
process.exit(result.status ?? 1);
Expand Down
7 changes: 6 additions & 1 deletion scripts/harness_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@
# companion on the same docs contract; unknown metadata stays fail-safe.
"scripts/tests/test_build_docs.py.meta": ("test_build_docs.py",),
"scripts/unity_mcp_doctor.py": ("test_unity_mcp_doctor.py",),
".llm/mcp/unity-mcp-host.mjs": ("test_unity_mcp_host.py", "test_unity_mcp_settings.py"),
".llm/mcp/unity-mcp-host.mjs": ("test_mcp_catalog.py", "test_unity_mcp_host.py", "test_unity_mcp_host_python.py", "test_unity_mcp_settings.py"),
"scripts/unity_mcp_settings.py": ("test_unity_mcp_host.py", "test_unity_mcp_settings.py"),
"scripts/unity_mcp_settings.py.meta": ("test_unity_mcp_settings.py",),
"scripts/tests/test_unity_mcp_settings.py.meta": ("test_unity_mcp_settings.py",),
# The live doctor path selects the doctor contract suite alongside the
# coarse mcp contract suites the subtree prefix already selects (#269).
"scripts/mcp_configure.py": ("test_mcp_aliases.py", "test_mcp_catalog.py", "test_mcp_codex_config.py", "test_mcp_credentials.py", "test_mcp_json_config.py", "test_mcp_sync.py"),
"scripts/mcp_configure.py.meta": ("test_mcp_aliases.py", "test_mcp_catalog.py", "test_mcp_codex_config.py", "test_mcp_credentials.py", "test_mcp_json_config.py", "test_mcp_sync.py"),
"scripts/unity_mcp_host.py": ("test_unity_mcp_host.py", "test_unity_mcp_host_python.py", "test_unity_mcp_settings.py"),
"scripts/unity_mcp_host.py.meta": ("test_unity_mcp_host.py", "test_unity_mcp_host_python.py", "test_unity_mcp_settings.py"),
"scripts/tests/test_unity_mcp_host_python.py.meta": ("test_unity_mcp_host_python.py",),
"scripts/mcp_catalog.py": ("test_mcp_catalog.py", "test_mcp_credentials.py", "test_mcp_sync.py"),
"scripts/mcp_catalog.py.meta": ("test_mcp_catalog.py", "test_mcp_credentials.py", "test_mcp_sync.py"),
"scripts/tests/test_mcp_catalog.py.meta": ("test_mcp_catalog.py",),
Expand Down
24 changes: 24 additions & 0 deletions scripts/tests/test_mcp_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,30 @@ def test_configurator_falls_back_to_python_without_uv(self) -> None:
self.assertNotIn(b'Python 3.9+', result.stderr)
self.assertFalse((root / '.mcp.json').exists())

@unittest.skipUnless(os.name == 'posix' and shutil.which('node'), 'placeholder PATH fixture needs POSIX and Node')
def test_legacy_launchers_skip_successful_non_interpreter_placeholders(self) -> None:
with tempfile.TemporaryDirectory(prefix='python placeholder ') as directory:
root = Path(directory)
binary = root / 'bin'
binary.mkdir()
placeholder = binary / 'python3'
placeholder.write_text('#!/bin/sh\nexit 0\n', encoding='utf-8')
placeholder.chmod(0o755)
(binary / 'python').symlink_to(sys.executable)
environment = {key: value for key, value in os.environ.items()
if not key.startswith(('GITHUB_', 'ZAI_', 'Z_AI_', 'UNITY_'))}
environment.update(PATH=str(binary), DATAVIZ_MCP_WORKSPACE=str(root), HOME=str(root / 'home'),
CODEX_HOME=str(root / 'codex'), NANOCODER_CONFIG_DIR=str(root / 'nano'))
folder = Path(__file__).resolve().parents[2] / '.llm/mcp'
for entry, flag, expected in (('configure.mjs', '--check', b'Servers: unity'),
('unity-mcp-host.mjs', '--help', b'usage:')):
with self.subTest(entry=entry):
result = subprocess.run([str(shutil.which('node')), str(folder / entry), flag], env=environment,
cwd=root, capture_output=True, check=False)
self.assertEqual(result.returncode, 0, result.stderr.decode('utf-8', errors='replace'))
self.assertIn(expected, result.stdout)
self.assertFalse((root / '.mcp.json').exists())
Comment thread
cursor[bot] marked this conversation as resolved.

def test_cli_uses_utf8_bytes_under_legacy_stdio_encoding(self) -> None:
environment = dict(os.environ, PYTHONIOENCODING='cp1252')
result = subprocess.run([sys.executable, str(Path(__file__).resolve().parents[1] / 'mcp_catalog.py')],
Expand Down
Loading
Loading