diff --git a/.llm/context.md b/.llm/context.md index dc8e6b8..95bc363 100644 --- a/.llm/context.md +++ b/.llm/context.md @@ -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` → diff --git a/.llm/mcp/README.md b/.llm/mcp/README.md index 81b7852..00caea2 100644 --- a/.llm/mcp/README.md +++ b/.llm/mcp/README.md @@ -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 @@ -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 ` 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. @@ -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. diff --git a/.llm/mcp/configure.mjs b/.llm/mcp/configure.mjs index 45f5898..94c78f1 100644 --- a/.llm/mcp/configure.mjs +++ b/.llm/mcp/configure.mjs @@ -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); diff --git a/.llm/mcp/unity-mcp-host.mjs b/.llm/mcp/unity-mcp-host.mjs index 060968b..877894a 100644 --- a/.llm/mcp/unity-mcp-host.mjs +++ b/.llm/mcp/unity-mcp-host.mjs @@ -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); diff --git a/scripts/harness_scope.py b/scripts/harness_scope.py index 73d1232..a028b58 100644 --- a/scripts/harness_scope.py +++ b/scripts/harness_scope.py @@ -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",), diff --git a/scripts/tests/test_mcp_catalog.py b/scripts/tests/test_mcp_catalog.py index 8b9efa0..f69661b 100644 --- a/scripts/tests/test_mcp_catalog.py +++ b/scripts/tests/test_mcp_catalog.py @@ -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()) + 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')], diff --git a/scripts/tests/test_unity_mcp_host.py b/scripts/tests/test_unity_mcp_host.py index 67566ef..79d2d8a 100644 --- a/scripts/tests/test_unity_mcp_host.py +++ b/scripts/tests/test_unity_mcp_host.py @@ -154,38 +154,29 @@ def release_after_locked_cleanup(path: Path) -> None: time.sleep(1.8) """ - -CONFIG_SYNC_FIXTURE = """ -import fs from 'node:fs'; -fs.writeFileSync('sync-port.pending', process.env.UNITY_MCP_HTTP_PORT); -fs.renameSync('sync-port.pending', 'sync-port'); -setTimeout(() => process.exit(0), 1800); -""" - - -@unittest.skipUnless(shutil.which('node'), 'node is required') class ConfigSyncPublicationTests(unittest.TestCase): def test_port_is_published_only_after_the_write_completes(self) -> None: with contextlib.ExitStack() as cleanup: directory = cleanup.enter_context(tempfile.TemporaryDirectory(prefix='config-sync-publication-')) root = Path(directory) - preload = root / 'pause-write.mjs' + preload = root / 'pause_write.py' preload.write_text(""" -import fs from 'node:fs'; -const write = fs.writeFileSync; -fs.writeFileSync = (path, data, ...options) => { - write(path, ''); - write('writer-ready', ''); - fs.readSync(0, Buffer.alloc(1), 0, 1, null); - return write(path, data, ...options); -}; +import runpy, sys +from pathlib import Path +write = Path.write_text +def paused(path, data, *args, **options): + write(path, '') + write(Path('writer-ready'), '') + sys.stdin.buffer.read(1) + return write(path, data, *args, **options) +Path.write_text = paused +runpy.run_path(sys.argv[1], run_name='__main__') """, encoding='utf-8') - fixture = root / 'configure.mjs' - # Publication is synchronous; this test does not need the bridge's background delay. - fixture.write_text(CONFIG_SYNC_FIXTURE + '\nprocess.exit(0);', encoding='utf-8') + fixture = root / 'configure.py' + fixture.write_text(PYTHON_CONFIG_SYNC_FIXTURE.replace('time.sleep(1.8)', ''), encoding='utf-8') env = dict(os.environ, UNITY_MCP_HTTP_PORT='53386') process = cleanup.enter_context(subprocess.Popen( - [str(shutil.which('node')), '--import', preload.as_uri(), str(fixture)], + [PYTHON, str(preload), str(fixture)], cwd=root, env=env, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, )) cleanup.callback(process.kill) @@ -205,22 +196,16 @@ def test_port_is_published_only_after_the_write_completes(self) -> None: self.assertEqual(port_file.read_text(encoding='utf-8'), '53386') -@unittest.skipUnless(shutil.which('node'), 'node is required') -@unittest.skip('Retired Node implementation; Python subclass runs the shared contracts.') class HostTests(unittest.TestCase): - python_bridge = False - def setUp(self) -> None: self.root = Path(tempfile.mkdtemp(prefix='unity-mcp-host-')) self.addCleanup(cleanup_fixture_directory, self.root) script_dir = self.root / '.llm' / 'mcp' script_dir.mkdir(parents=True) (self.root / 'scripts').mkdir() - source = REPO / ('scripts/unity_mcp_host.py' if self.python_bridge else '.llm/mcp/unity-mcp-host.mjs') - self.script = self.root / source.relative_to(REPO) - shutil.copyfile(source, self.script) - if self.python_bridge: - shutil.copyfile(REPO / '.llm/mcp/unity-mcp-host.mjs', script_dir / 'unity-mcp-host.mjs') + self.script = self.root / 'scripts/unity_mcp_host.py' + shutil.copyfile(REPO / 'scripts/unity_mcp_host.py', self.script) + shutil.copyfile(REPO / '.llm/mcp/unity-mcp-host.mjs', script_dir / 'unity-mcp-host.mjs') shutil.copyfile(REPO / 'scripts' / 'unity_mcp_settings.py', self.root / 'scripts' / 'unity_mcp_settings.py') (self.root / 'mcp').write_text(FAKE_CLI, encoding='utf-8') self.home = self.root / 'home' @@ -237,27 +222,6 @@ def setUp(self) -> None: self.env_file.write_text(f'\ufeffexport UNITY_CLI="{PYTHON}"\nUNITY_MCP_PROJECT="{self.project}"\nUNITY_MCP_TOKEN=fixture-secret\nUNITY_MCP_TIMEOUT_MS=1500\n', encoding='utf-8') self.log = (self.root / 'bridge.log').open('w+', encoding='utf-8') self.addCleanup(self.log.close) - self.node_options: list[str] = [] - if not self.python_bridge and self._testMethodName == 'test_auth_notifications_and_parallel_request_ids': - observer = self.root / 'observe-response-end.mjs' - observer.write_text(""" -import http from 'node:http'; -const createServer = http.createServer; -http.createServer = (handler) => createServer((request, response) => { - const end = response.end.bind(response); - response.end = (...args) => { - if (request.headers['x-dataviz-drain-probe']) { - console.log('[drain-probe]' + JSON.stringify({ - probe: request.headers['x-dataviz-drain-probe'], - drained: request.readableEnded, - })); - } - return end(...args); - }; - handler(request, response); -}); -""", encoding='utf-8') - self.node_options = ['--import', observer.as_uri()] self.launch() self.addCleanup(self.stop) @@ -272,8 +236,7 @@ def launch(self, command: Optional[list[str]] = None) -> None: time.sleep(0.02) def command(self) -> list[str]: - launcher = [PYTHON] if self.python_bridge else [str(shutil.which('node')), *self.node_options] - return [*launcher, str(self.script), '--host', '127.0.0.1', '--port', str(self.port)] + return [PYTHON, str(self.script), '--host', '127.0.0.1', '--port', str(self.port)] def logs(self) -> str: self.log.flush() @@ -372,10 +335,7 @@ def test_auth_notifications_and_parallel_request_ids(self) -> None: self.assertEqual(response.getheader('Allow'), 'POST, DELETE') client.sendall(b'{}') self.assertEqual(client.recv(1), b'', 'Close only after consuming the complete request.') - observations = [json.loads(line.split('[drain-probe]', 1)[1]) - for line in self.logs().splitlines() if '[drain-probe]' in line] - if not self.python_bridge: - self.assertIn({'probe': probe, 'drained': True}, observations) + # An abandoning client must leave the bridge responsive, without a retry. with socket.create_connection(('127.0.0.1', self.port), timeout=5) as client: @@ -501,105 +461,7 @@ def test_loopback_occupant_forces_wildcard_bridge_to_another_port(self) -> None: self.assertEqual(self.http('/healthz')[1]['bridgePid'], self.process.pid) self.assertEqual(self.rpc('initialize')['result']['serverInfo']['name'], 'fixture') - def test_failed_self_check_exits_before_publishing_port(self) -> None: - if self.python_bridge: - self.skipTest("Python startup failures are covered by route-injection unit tests.") - self.stop() - preload = self.root / 'redirect-health.mjs' - preload.write_text("import http from 'node:http'; const get = http.get; http.get = (options, callback) => get({ ...options, path: '/wrong-endpoint' }, callback);", encoding='utf-8') - env = dict(self.env, NODE_OPTIONS=f'--import={preload.as_uri()}') - before = self.env_file.read_text(encoding='utf-8') - result = subprocess.run(self.command(), cwd=self.root, env=env, capture_output=True, text=True, timeout=8) - self.assertEqual(result.returncode, 1, result.stdout + result.stderr) - self.assertIn('Local HTTP self-check failed', result.stderr) - self.assertNotIn('Local HTTP self-check passed', result.stdout) - self.assertEqual(before, self.env_file.read_text(encoding='utf-8')) - def test_intercepted_docker_route_selects_and_publishes_a_verified_port(self) -> None: - if self.python_bridge: - self.skipTest("Python startup failures are covered by route-injection unit tests.") - self.stop() - for mode in ('wrong-instance', 'silent', 'missing-dns'): - with self.subTest(mode=mode), contextlib.ExitStack() as cleanup: - cleanup.callback(self.stop) - preload = self.root / 'docker-route.mjs' - preload.write_text(""" -import http from 'node:http'; -import os from 'node:os'; -os.platform = () => 'win32'; -const createServer = http.createServer; -http.createServer = (handler) => createServer((request, response) => { - if (request.url === '/intercepted') { - if (process.env.ROUTE_MODE === 'silent') return; - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ instanceId: 'different-listener' })); - return; - } - handler(request, response); -}); -const get = http.get; -http.get = (options, callback) => { - if (options.hostname !== 'host.docker.internal') return get(options, callback); - if (process.env.ROUTE_MODE === 'missing-dns') { - return get({ ...options, hostname: 'localhost', lookup: (_host, _options, done) => { - const error = new Error('No Docker Desktop DNS'); - error.code = 'ENOTFOUND'; - done(error); - } }, callback); - } - return get({ ...options, hostname: '127.0.0.1', - path: String(options.port) === process.env.INTERCEPT_PORT ? '/intercepted' : options.path, - }, callback); -}; -""", encoding='utf-8') - initial = self.port - self.env.update(ROUTE_MODE=mode, INTERCEPT_PORT=str(initial)) - self.node_options = ['--import', preload.as_uri()] - offset = len(self.logs()) - command = self.command() - del command[-4:-2] # Remove --host 127.0.0.1, retaining Node preload. - self.launch(command) - selected = next(line.split('=', 1)[1] for line in self.env_file.read_text(encoding='utf-8').splitlines() if line.startswith('UNITY_MCP_HTTP_PORT=')) - self.port = int(selected) - if mode == 'missing-dns': - self.assertEqual(self.port, initial) - self.assertIn('only the local route was verified', self.logs()[offset:]) - else: - self.assertNotEqual(self.port, initial) - self.assertIn('Docker host route failed', self.logs()[offset:]) - self.assertIn('Docker host route verified', self.logs()[offset:]) - self.assertEqual(self.http('/healthz')[1]['bridgePid'], self.process.pid) - self.assertEqual(self.rpc('initialize')['result']['serverInfo']['name'], 'fixture') - - def test_existing_bridge_with_intercepted_docker_route_is_not_reused(self) -> None: - if self.python_bridge: - self.skipTest("Python startup failures are covered by route-injection unit tests.") - old = self.process - self.addCleanup(old.wait, 5) - self.addCleanup(old.terminate) - preload = self.root / 'existing-docker-route.mjs' - preload.write_text(""" -import http from 'node:http'; -import os from 'node:os'; -os.platform = () => 'win32'; -const get = http.get; -http.get = (options, callback) => get(options.hostname === 'host.docker.internal' - ? { ...options, hostname: '127.0.0.1', - path: String(options.port) === process.env.INTERCEPT_PORT ? '/intercepted' : options.path } - : options, callback); -""", encoding='utf-8') - self.env['INTERCEPT_PORT'] = str(self.port) - self.node_options = ['--import', preload.as_uri()] - command = self.command() - del command[-4:-2] - initial = self.port - offset = len(self.logs()) - self.launch(command) - self.port = int(next(line.split('=', 1)[1] for line in self.env_file.read_text(encoding='utf-8').splitlines() if line.startswith('UNITY_MCP_HTTP_PORT='))) - self.assertNotEqual(self.port, initial) - self.assertNotIn('nothing to do', self.logs()[offset:]) - self.assertIn('Docker host route verified', self.logs()[offset:]) - self.assertEqual(self.http('/healthz')[1]['bridgePid'], self.process.pid) def test_repeated_start_recognizes_absolute_cli_path(self) -> None: before = self.env_file.read_text(encoding='utf-8') @@ -610,8 +472,8 @@ def test_repeated_start_recognizes_absolute_cli_path(self) -> None: def test_slow_config_sync_keeps_http_responsive_and_uses_selected_port(self) -> None: self.stop() - configure = self.root / 'scripts/mcp_configure.py' if self.python_bridge else self.script.parent / 'configure.mjs' - configure.write_text(PYTHON_CONFIG_SYNC_FIXTURE if self.python_bridge else CONFIG_SYNC_FIXTURE, encoding='utf-8') + configure = self.root / 'scripts/mcp_configure.py' + configure.write_text(PYTHON_CONFIG_SYNC_FIXTURE, encoding='utf-8') self.env['UNITY_MCP_AUTO_SYNC'] = '1' self.env['UNITY_MCP_HTTP_PORT'] = '9020' self.env_file.write_text(self.env_file.read_text(encoding='utf-8').replace(f'UNITY_MCP_HTTP_PORT={self.port}', 'UNITY_MCP_HTTP_PORT=9020'), encoding='utf-8') @@ -627,11 +489,6 @@ def test_slow_config_sync_keeps_http_responsive_and_uses_selected_port(self) -> self.assertEqual(port_file.read_text(encoding='utf-8'), str(self.port)) time.sleep(1.8) - -class PythonHostTests(HostTests): - __unittest_skip__ = False - python_bridge = True - def test_stalled_cli_input_does_not_block_request_timeouts_or_health(self) -> None: self.rpc('initialize') child_pid = self.http('/healthz')[1]['pid']