From ee418c7b567365ac033e0eacf093ad295acd8e81 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sun, 18 Oct 2026 21:35:24 +0000 Subject: [PATCH 1/2] Keep bridge verification and launchers in Python Retired Node hooks duplicate the Python bridge contracts. Replace the remaining JavaScript publication fixture with Python, remove the retired hooks, and map the new Python subjects to their contract suites. A zero-exit placeholder interpreter reproduced silent success without configuration. Both compatibility launchers now require a positive interpreter marker; uv must report its version. The placeholder regression fails before repair and passes after. Existing script names remain compatible. Strict pyright, Python 3.9 bridge contracts, the final fast check, and all 17 full gate steps pass. Native Windows validation covered the bridge and startup review repairs. The Python bridge also passed 395 EditMode and 15 PlayMode tests on the live Unity 6000.5.2f1 host; all eleven pre-existing asset GUIDs remain. Temporary validation processes and workspaces were removed. Refs #171. Inline release-workflow orchestration remains for the next bounded migration layer. --- .llm/context.md | 2 +- .llm/mcp/README.md | 12 +- .llm/mcp/configure.mjs | 8 +- .llm/mcp/unity-mcp-host.mjs | 8 +- scripts/harness_scope.py | 5 + scripts/tests/test_mcp_catalog.py | 24 ++++ scripts/tests/test_unity_mcp_host.py | 185 +++------------------------ 7 files changed, 67 insertions(+), 177 deletions(-) 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..44fa525 100644 --- a/scripts/harness_scope.py +++ b/scripts/harness_scope.py @@ -68,6 +68,11 @@ "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'] From 14e6ea9830d2254d1006dae6671e2ddf1cb0792d Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sun, 18 Oct 2026 21:40:45 +0000 Subject: [PATCH 2/2] Select interpreter checks for bridge launcher changes Launcher-only changes must run the positive interpreter probes as well as the shared HTTP and Python startup contracts. The original selection omitted the catalog suite that owns the new data-driven launcher regression. Verified the launcher probe selection before and after the repair; catalog and scope suites pass, as does the changed-file fast check. Refs #171; addresses PR #302 review. --- scripts/harness_scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/harness_scope.py b/scripts/harness_scope.py index 44fa525..a028b58 100644 --- a/scripts/harness_scope.py +++ b/scripts/harness_scope.py @@ -62,7 +62,7 @@ # 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",),