From cb10aa0ef5c0e55ea08c74cb6f4dcd90f6605acb Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sun, 18 Oct 2026 20:56:17 +0000 Subject: [PATCH 1/3] Add a typed Python Unity HTTP bridge The Unity HTTP host is the last substantive Node implementation in the tooling migration. Add a direct Python bridge while keeping the existing entry active for a bounded cutover. Keep lazy CLI ownership, ID remapping, authentication, batch and notification semantics, timeout and crash recovery, early-body draining, dynamic port publication, Docker route identity checks, and responsive config sync. Share real HTTP contracts with the old implementation and cover Python route failures separately. Strict pyright and actual Python 3.9 contracts pass. The final 17-step gate passes the full harness suite. An isolated native Windows startup verifies the Docker route; container initialization, 160-tool discovery, and editor status reach the pinned Unity project. The validation process tree was stopped and its temporary workspace deleted. C# remains at the previously validated 395 EditMode and 15 PlayMode passes. Refs #171. A later bounded layer switches the existing host entry point. --- .llm/mcp/README.md | 3 + scripts/tests/test_unity_mcp_host.py | 91 +++- scripts/tests/test_unity_mcp_host_python.py | 101 ++++ .../tests/test_unity_mcp_host_python.py.meta | 2 + scripts/unity_mcp_host.py | 437 ++++++++++++++++++ scripts/unity_mcp_host.py.meta | 2 + 6 files changed, 613 insertions(+), 23 deletions(-) create mode 100644 scripts/tests/test_unity_mcp_host_python.py create mode 100644 scripts/tests/test_unity_mcp_host_python.py.meta create mode 100644 scripts/unity_mcp_host.py create mode 100644 scripts/unity_mcp_host.py.meta diff --git a/.llm/mcp/README.md b/.llm/mcp/README.md index e7b7f86..17da0f1 100644 --- a/.llm/mcp/README.md +++ b/.llm/mcp/README.md @@ -183,3 +183,6 @@ directly to the host bridge. Match health's `bridgePid` to the listener owner; h The universal configurator runs in typed Python through `uv run scripts/mcp_configure.py`. The legacy Node entry remains a launcher with Python 3.9+ fallback. The sync wrapper no longer requires Node. + +Run the typed Python bridge directly with `uv run scripts/unity_mcp_host.py` on the host. It uses the same settings, health identity, route checks, and dynamic port publication as +the legacy Node command. diff --git a/scripts/tests/test_unity_mcp_host.py b/scripts/tests/test_unity_mcp_host.py index 28b2469..f792f62 100644 --- a/scripts/tests/test_unity_mcp_host.py +++ b/scripts/tests/test_unity_mcp_host.py @@ -15,7 +15,7 @@ import time import unittest from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Optional from unittest import mock from urllib.error import HTTPError from urllib.request import Request, urlopen @@ -126,21 +126,31 @@ def release_after_locked_cleanup(path: Path) -> None: self.assertFalse(root.exists()) FAKE_CLI = r""" -const readline = require('node:readline'); -readline.createInterface({ input: process.stdin }).on('line', line => { - const message = JSON.parse(line); - if (message.method === 'crash') { - process.stdout.write('partial output'); - process.exit(1); - } - if (message.id === undefined) return; - const result = message.method === 'initialize' - ? { serverInfo: { name: 'fixture' }, capabilities: {}, protocolVersion: '2025-03-26' } - : { method: message.method, params: message.params }; - setTimeout(() => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }) + '\n'), 5); -}); +import json, os, sys, time +for line in sys.stdin: + message = json.loads(line) + if message.get('method') == 'crash': + sys.stdout.write('partial output') + sys.stdout.flush() + os._exit(1) + if message.get('method') == 'never-respond': + continue + if 'id' not in message: + continue + result = dict(serverInfo=dict(name='fixture'), capabilities={}, protocolVersion='2025-03-26') if message.get('method') == 'initialize' else dict(method=message.get('method'), params=message.get('params')) + time.sleep(0.005) + print(json.dumps(dict(jsonrpc='2.0', id=message['id'], result=result)), flush=True) +""" + +PYTHON_CONFIG_SYNC_FIXTURE = """ +import os, time +from pathlib import Path +Path('sync-port.pending').write_text(os.environ['UNITY_MCP_HTTP_PORT']) +Path('sync-port.pending').replace('sync-port') +time.sleep(1.8) """ + CONFIG_SYNC_FIXTURE = """ import fs from 'node:fs'; fs.writeFileSync('sync-port.pending', process.env.UNITY_MCP_HTTP_PORT); @@ -193,14 +203,17 @@ def test_port_is_published_only_after_the_write_completes(self) -> None: @unittest.skipUnless(shutil.which('node'), 'node is required') 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.script = script_dir / 'unity-mcp-host.mjs' - shutil.copyfile(REPO / '.llm' / 'mcp' / self.script.name, self.script) (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) 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' @@ -214,11 +227,11 @@ def setUp(self) -> None: self.env = {key: value for key, value in os.environ.items() if not key.startswith('UNITY_')} self.env.update(HOME=str(self.home), USERPROFILE=str(self.home), UNITY_MCP_AUTO_SYNC='0') self.env_file = self.root / '.env.local' - self.env_file.write_text(f'\ufeffexport UNITY_CLI="{shutil.which("node")}"\nUNITY_MCP_PROJECT="{self.project}"\nUNITY_MCP_TOKEN=fixture-secret\nUNITY_MCP_TIMEOUT_MS=1500\n', encoding='utf-8') + self.env_file.write_text(f'\ufeffexport UNITY_CLI="{sys.executable}"\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 self._testMethodName == 'test_auth_notifications_and_parallel_request_ids': + 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'; @@ -252,7 +265,8 @@ def launch(self, command: Optional[list[str]] = None) -> None: time.sleep(0.02) def command(self) -> list[str]: - return [str(shutil.which('node')), *self.node_options, str(self.script), '--host', '127.0.0.1', '--port', str(self.port)] + launcher = [sys.executable] 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)] def logs(self) -> str: self.log.flush() @@ -283,7 +297,7 @@ def stop(self) -> None: if taskkill_failure is not None: self.fail(taskkill_failure) - def http(self, route: str, payload: Optional[Dict[str, Any]] = None, token: str = 'fixture-secret', method: Optional[str] = None) -> tuple[int, Any]: + def http(self, route: str, payload: Optional[object] = None, token: str = 'fixture-secret', method: Optional[str] = None) -> tuple[int, Any]: headers = {'content-type': 'application/json', 'authorization': f'Bearer {token}'} data = None if payload is None else json.dumps(payload).encode() request = Request(f'http://127.0.0.1:{self.port}{route}', data=data, headers=headers, method=method) @@ -353,7 +367,8 @@ def test_auth_notifications_and_parallel_request_ids(self) -> None: 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] - self.assertIn({'probe': probe, 'drained': True}, observations) + 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: @@ -364,6 +379,25 @@ def test_auth_notifications_and_parallel_request_ids(self) -> None: self.assertEqual(json.loads(response.read()), {'error': 'unauthorized'}) self.assertEqual(self.http('/healthz')[0], 200) + def test_timeout_parse_errors_and_batch_requests_keep_the_bridge_usable(self) -> None: + response = self.rpc('never-respond') + self.assertEqual(response['error']['code'], -32000) + self.assertIn('Timed out after 1500ms', response['error']['message']) + batch = [dict(jsonrpc='2.0', id='first', method='first'), + dict(jsonrpc='2.0', method='notifications/initialized'), + dict(jsonrpc='2.0', id='second', method='second')] + status, result = self.http('/mcp', batch) + self.assertEqual(status, 200) + self.assertEqual([item['id'] for item in result], ['first', 'second']) + request = Request(f'http://127.0.0.1:{self.port}/mcp', data=b'{', + headers={'Authorization': 'Bearer fixture-secret'}) + with self.assertRaises(HTTPError) as raised: + urlopen(request, timeout=5) + with raised.exception as error: + self.assertEqual(error.code, 400) + self.assertEqual(json.load(error)['error']['code'], -32700) + self.assertEqual(self.rpc('initialize')['result']['serverInfo']['name'], 'fixture') + def test_cli_crash_does_not_poison_restarted_stdout(self) -> None: self.rpc('initialize') result = self.rpc('crash') @@ -461,6 +495,8 @@ def test_loopback_occupant_forces_wildcard_bridge_to_another_port(self) -> None: 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') @@ -473,6 +509,8 @@ def test_failed_self_check_exits_before_publishing_port(self) -> None: 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: @@ -527,6 +565,8 @@ def test_intercepted_docker_route_selects_and_publishes_a_verified_port(self) -> 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) @@ -563,8 +603,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.script.parent / 'configure.mjs' - configure.write_text(CONFIG_SYNC_FIXTURE, encoding='utf-8') + 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') 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') @@ -581,6 +621,11 @@ def test_slow_config_sync_keeps_http_responsive_and_uses_selected_port(self) -> time.sleep(1.8) +class PythonHostTests(HostTests): + __unittest_skip__ = False + python_bridge = True + + class CleanupTests(unittest.TestCase): def test_taskkill_failure_requires_a_live_process(self) -> None: for state in ('already-exited', 'exits-while-waiting', 'remains-live'): diff --git a/scripts/tests/test_unity_mcp_host_python.py b/scripts/tests/test_unity_mcp_host_python.py new file mode 100644 index 0000000..f4b74ed --- /dev/null +++ b/scripts/tests/test_unity_mcp_host_python.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Pin Python bridge startup publication and Docker-route failure handling.""" +from __future__ import annotations + +from pathlib import Path +import socket +import sys +import threading +import unittest +from unittest.mock import Mock, patch +from urllib.error import URLError + +sys.dont_write_bytecode = True +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import unity_mcp_host as host +from unity_mcp_settings import BridgeSettings + + +def settings() -> BridgeSettings: + return BridgeSettings(cliCommand='unused', cliArgs=['mcp'], projectPath='', token='', port='9020', + timeout='100', autoSync='0', warnings=[]) + + +class StartupTests(unittest.TestCase): + def test_docker_route_requires_the_same_instance_before_publication(self) -> None: + with patch.object(host.sys, 'platform', 'win32'), patch.object(host, 'log'): + for value in ('different-listener', None, 'expected'): + with self.subTest(value=value), patch.object(host, 'read_health', return_value={'instanceId': value}): + if value == 'expected': + host.verify_docker_host_route(9020, '0.0.0.0', 'expected') + else: + with self.assertRaises(host.DockerHostRouteError): + host.verify_docker_host_route(9020, '0.0.0.0', 'expected') + for reason in (TimeoutError('silent listener'), socket.gaierror(socket.EAI_AGAIN, 'temporary DNS failure')): + with self.subTest(reason=reason), patch.object(host, 'read_health', side_effect=URLError(reason)): + with self.assertRaises(host.DockerHostRouteError): + host.verify_docker_host_route(9020, '::', 'expected') + with patch.object(host, 'read_health', side_effect=URLError(socket.gaierror(socket.EAI_NONAME, 'missing Docker DNS'))): + host.verify_docker_host_route(9020, '0.0.0.0', 'expected') + with patch.object(host, 'read_health') as read: + host.verify_docker_host_route(9020, '127.0.0.1', 'expected') + read.assert_not_called() + + def test_intercepted_existing_bridge_is_not_reused(self) -> None: + with patch.object(host, 'read_health', return_value={'bridge': 'dataviz-unity-mcp', 'instanceId': 'existing'}), \ + patch.object(host, 'verify_docker_host_route', side_effect=host.DockerHostRouteError('intercepted')): + self.assertFalse(host.existing_bridge(9020, '0.0.0.0')) + + def test_failed_local_check_never_publishes_and_closes_the_candidate(self) -> None: + server = Mock() + with patch.object(host, 'existing_bridge', return_value=False), patch.object(host, 'port_free', return_value=True), \ + patch.object(host, 'BridgeServer', return_value=server), patch.object(host.threading, 'Thread'), \ + patch.object(host, 'read_health', side_effect=ValueError('wrong instance')), patch.object(host, 'persist_port') as persist: + with self.assertRaisesRegex(ValueError, 'Local HTTP self-check failed'): + host.serve(Path('.'), settings(), 9020, '127.0.0.1', threading.Event()) + persist.assert_not_called() + server.shutdown.assert_called_once() + server.server_close.assert_called_once() + + def test_intercepted_candidate_is_closed_and_only_verified_port_is_published(self) -> None: + servers: list[Mock] = [] + bridges: list[host.Bridge] = [] + + def create(hostname: str, port: int, bridge: host.Bridge) -> Mock: + server = Mock() + servers.append(server) + bridges.append(bridge) + return server + + def health(port: int, hostname: str) -> host.JsonMap: + return {'instanceId': bridges[-1].instance} + + done = threading.Event() + done.set() + with patch.object(host, 'existing_bridge', return_value=False), patch.object(host, 'port_free', return_value=True), \ + patch.object(host, 'BridgeServer', side_effect=create), patch.object(host.threading, 'Thread'), \ + patch.object(host, 'read_health', side_effect=health), patch.object(host, 'log'), \ + patch.object(host, 'verify_docker_host_route', side_effect=[host.DockerHostRouteError('silent'), None]), \ + patch.object(host, 'persist_port', return_value=False) as persist: + self.assertEqual(host.serve(Path('.'), settings(), 9020, '0.0.0.0', done), 0) + persist.assert_called_once_with(Path('.'), 9021) + self.assertEqual(len(servers), 2) + for server in servers: + server.shutdown.assert_called_once() + server.server_close.assert_called_once() + + def test_invalid_json_and_batch_notifications_keep_protocol_responses(self) -> None: + bridge = host.Bridge(settings()) + self.addCleanup(bridge.close) + self.assertEqual(bridge.handle(7), host.rpc_error(None, -32600, 'Invalid JSON-RPC message.')) + self.assertIsNone(bridge.handle([])) + response = {'jsonrpc': '2.0', 'id': 'original', 'result': {'value': '日本語'}} + with patch.object(bridge, 'request', return_value=response), patch.object(bridge, 'write') as write: + self.assertEqual(bridge.handle([{'id': 'original', 'method': 'test'}, {'method': 'initialized'}]), response) + write.assert_called_once_with({'method': 'initialized'}) + with patch.object(bridge, 'write', side_effect=OSError('closed input')), patch.object(host, 'log'): + self.assertIsNone(bridge.handle({'method': 'initialized'})) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_unity_mcp_host_python.py.meta b/scripts/tests/test_unity_mcp_host_python.py.meta new file mode 100644 index 0000000..0fd7ce9 --- /dev/null +++ b/scripts/tests/test_unity_mcp_host_python.py.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 711115665c204f0887b656eadcbbb4d3 diff --git a/scripts/unity_mcp_host.py b/scripts/unity_mcp_host.py new file mode 100644 index 0000000..61af41d --- /dev/null +++ b/scripts/unity_mcp_host.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 +"""Bridge the official Unity CLI MCP server to stateless HTTP (issue #171).""" +from __future__ import annotations + +import argparse +from contextlib import suppress +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +import hmac +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import re +import signal +import socket +import subprocess +import sys +import threading +import time +from types import FrameType +from typing import Any, Dict, Optional, Sequence, cast +from urllib.error import URLError +from urllib.parse import urlsplit +from urllib.request import ProxyHandler, build_opener +import uuid + +sys.dont_write_bytecode = True +from unity_mcp_settings import BridgeSettings, resolve_settings + +JsonMap = Dict[str, Any] +ROOT = Path(__file__).resolve().parent.parent + + +def log(message: str, error: bool = False) -> None: + stream = sys.stderr.buffer if error else sys.stdout.buffer + stream.write((message + '\n').encode('utf-8', errors='replace')) + stream.flush() + + +def rpc_error(identifier: object, code: int, message: str) -> JsonMap: + return {'jsonrpc': '2.0', 'id': identifier, 'error': {'code': code, 'message': message}} + + +@dataclass +class Pending: + identifier: object + ready: threading.Event = field(default_factory=threading.Event) + response: Optional[JsonMap] = None + + +class Bridge: + def __init__(self, settings: BridgeSettings) -> None: + self.settings = settings + self.instance = str(uuid.uuid4()) + self.child: Optional[subprocess.Popen[bytes]] = None + self.lock = threading.RLock() + self.pending: Dict[int, Pending] = {} + self.next_id = 0 + self.last_spawn = float('-inf') + + def fail_pending(self, message: str) -> None: + for pending in self.pending.values(): + pending.response = rpc_error(pending.identifier, -32000, message) + pending.ready.set() + self.pending.clear() + + def retire(self, child: subprocess.Popen[bytes], reason: str) -> None: + with self.lock: + if self.child is not child: + return + self.child = None + self.fail_pending(reason) + with suppress(OSError): + child.kill() + if child.stdin is not None: + with suppress(OSError): + child.stdin.close() + + def read_stdout(self, child: subprocess.Popen[bytes]) -> None: + assert child.stdout is not None + for line in child.stdout: + if not line.strip(): + continue + try: + parsed: object = json.loads(line.decode('utf-8', errors='replace')) + if not isinstance(parsed, dict): + raise ValueError('Expected a JSON object') + message = cast(JsonMap, parsed) + except ValueError: + log('[unity-mcp] non-JSON stdout: ' + line.decode('utf-8', errors='replace').strip(), True) + continue + identifier = message.get('id') + if identifier is None: + continue + with self.lock: + if self.child is not child: + continue + pending = self.pending.pop(identifier, None) if isinstance(identifier, int) else None + if pending is not None: + pending.response = {**message, 'id': pending.identifier} + pending.ready.set() + elif message.get('method'): + try: + self.write(rpc_error(identifier, -32601, 'Method not supported by bridge: ' + str(message['method']))) + except (OSError, ValueError): + pass + self.retire(child, 'Unity CLI exited; retry the request.') + child.wait() + child.stdout.close() + + def read_stderr(self, child: subprocess.Popen[bytes]) -> None: + assert child.stderr is not None + for line in child.stderr: + log('[unity mcp] ' + line.decode('utf-8', errors='replace').rstrip(), True) + child.stderr.close() + + def ensure_child(self) -> subprocess.Popen[bytes]: + if self.child is not None and self.child.poll() is None: + return self.child + if time.monotonic() - self.last_spawn < 1: + raise ValueError('Unity CLI is not startable; install the Unity CLI or set UNITY_CLI to its path.') + self.last_spawn = time.monotonic() + self.fail_pending('Unity CLI restarted; retry the request.') + self.child = subprocess.Popen([self.settings['cliCommand'], *self.settings['cliArgs']], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + child = self.child + threading.Thread(target=self.read_stdout, args=(child,), daemon=True).start() + threading.Thread(target=self.read_stderr, args=(child,), daemon=True).start() + return child + + def write(self, message: JsonMap) -> None: + child = self.ensure_child() + assert child.stdin is not None + try: + child.stdin.write((json.dumps(message, ensure_ascii=False) + '\n').encode('utf-8')) + child.stdin.flush() + except OSError: + self.retire(child, 'Unity CLI input closed; retry the request.') + raise + + def request(self, message: JsonMap) -> JsonMap: + with self.lock: + try: + self.ensure_child() + self.next_id += 1 + identifier = self.next_id + pending = Pending(message['id']) + self.pending[identifier] = pending + self.write({**message, 'id': identifier}) + except (OSError, ValueError) as error: + self.pending.pop(self.next_id, None) + return rpc_error(message['id'], -32000, str(error)) + timeout = float(self.settings['timeout']) / 1000 + if not pending.ready.wait(timeout): + with self.lock: + self.pending.pop(identifier, None) + return rpc_error(message['id'], -32000, f"Timed out after {self.settings['timeout']}ms waiting for Unity CLI.") + return pending.response or rpc_error(message['id'], -32000, 'Unity CLI returned no response.') + + def handle(self, message: object) -> Optional[object]: + if isinstance(message, list): + entries = cast(list[object], message) + if not entries: + return None + with ThreadPoolExecutor(max_workers=min(len(entries), 32)) as executor: + responses = [result for result in executor.map(self.handle, entries) if result is not None] + return responses[0] if len(responses) == 1 else responses or None + if not isinstance(message, dict): + return rpc_error(None, -32600, 'Invalid JSON-RPC message.') + request = cast(JsonMap, message) + if request.get('id') is None: + try: + with self.lock: + self.write(request) + except (OSError, ValueError) as error: + log('[unity-mcp] dropped notification: ' + str(error), True) + return None + return self.request(request) + + def health(self) -> JsonMap: + with self.lock: + child = self.child + return {'bridge': 'dataviz-unity-mcp', 'instanceId': self.instance, 'bridgePid': os.getpid(), + 'ok': child is not None and child.poll() is None, + 'cli': ' '.join((self.settings['cliCommand'], *self.settings['cliArgs'])), + 'projectPath': self.settings['projectPath'], 'authRequired': bool(self.settings['token']), + 'pid': child.pid if child is not None else None} + + def close(self) -> None: + with self.lock: + child = self.child + self.child = None + self.fail_pending('Bridge stopped.') + if child is not None: + try: + child.kill() + child.wait(timeout=2) + except (OSError, subprocess.TimeoutExpired): + pass + if child.stdin is not None: + with suppress(OSError): + child.stdin.close() + + +class BridgeServer(ThreadingHTTPServer): + daemon_threads = True + bridge: Bridge + + def __init__(self, host: str, port: int, bridge: Bridge) -> None: + self.bridge = bridge + self.address_family = socket.AF_INET6 if ':' in host else socket.AF_INET + super().__init__((host, port), Handler) + + +class Handler(BaseHTTPRequestHandler): + def body(self) -> bytes: + if self.headers.get('Transfer-Encoding', '').lower() != 'chunked': + return self.rfile.read(int(self.headers.get('Content-Length') or 0)) + chunks: list[bytes] = [] + while True: + size = int(self.rfile.readline().split(b';', 1)[0].strip(), 16) + if size == 0: + while self.rfile.readline().strip(): + pass + return b''.join(chunks) + chunks.append(self.rfile.read(size)) + if self.rfile.read(2) != b'\r\n': + raise ValueError('Invalid chunk terminator') + + def respond(self, status: int, payload: Optional[object] = None, early: bool = False) -> None: + data = b'' if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8') + self.send_response(status) + if status != 204: + self.send_header('Content-Length', str(len(data))) + if payload is not None: + self.send_header('Content-Type', 'application/json') + if status == 405: + self.send_header('Allow', 'POST, DELETE') + self.end_headers() + if self.command != 'HEAD': + self.wfile.write(data) + self.wfile.flush() + if early: + self.body() + + def handle_rpc(self) -> None: + bridge = cast(BridgeServer, self.server).bridge + route = urlsplit(self.path).path + try: + if route == '/healthz': + self.respond(200, bridge.health(), early=True) + elif route != '/mcp': + self.respond(404, early=True) + elif bridge.settings['token'] and not hmac.compare_digest( + self.headers.get('Authorization', '').encode('utf-8'), ('Bearer ' + bridge.settings['token']).encode('utf-8') + ): + self.respond(401, {'error': 'unauthorized'}, early=True) + elif self.command == 'DELETE': + self.respond(204, early=True) + elif self.command != 'POST': + self.respond(405, early=True) + else: + try: + message: object = json.loads(self.body().decode('utf-8', errors='replace')) + except ValueError as error: + self.respond(400, rpc_error(None, -32700, 'Parse error: ' + str(error))) + return + result = bridge.handle(message) + self.respond(202 if result is None else 200, result) + except (OSError, ValueError): + # A disconnected HTTP client does not own the shared CLI lifetime. + pass + + do_GET = do_POST = do_DELETE = do_PUT = do_PATCH = do_HEAD = do_OPTIONS = do_TRACE = handle_rpc + + def log_message(self, format: str, *args: object) -> None: + pass + + +class DockerHostRouteError(ValueError): + pass + + +def read_health(port: int, hostname: str) -> JsonMap: + host = '[' + hostname + ']' if ':' in hostname else hostname + with build_opener(ProxyHandler({})).open(f'http://{host}:{port}/healthz', timeout=3) as response: + value: object = json.load(response) + if not isinstance(value, dict): + raise ValueError('Health returned no bridge object') + return cast(JsonMap, value) + + +def verify_docker_host_route(port: int, host: str, instance: str) -> None: + if sys.platform != 'win32' or host not in ('0.0.0.0', '::'): + return + try: + if not instance or read_health(port, 'host.docker.internal').get('instanceId') != instance: + raise ValueError('the Docker host endpoint answered as a different listener') + log(f'[unity-mcp] Docker host route verified on port {port} (instance {instance}).') + except (OSError, ValueError) as error: + if isinstance(error, URLError) and isinstance(error.reason, socket.gaierror) and error.reason.errno == socket.EAI_NONAME: + log('[unity-mcp] Docker host name is unavailable; only the local route was verified.') + return + raise DockerHostRouteError(f'Docker host route failed on host.docker.internal:{port}: {error}. ' + "Remove this port from VS Code's Ports view; trying another port.") from error + + +def port_free(port: int, host: str) -> bool: + for address in (host, '127.0.0.1') if host in ('0.0.0.0', '::', 'localhost') else (host,): + try: + with socket.socket(socket.AF_INET6 if ':' in address else socket.AF_INET) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind((address, port)) + except OSError: + return False + return True + + +def existing_bridge(port: int, host: str) -> bool: + try: + health = read_health(port, '127.0.0.1') + if health.get('bridge') != 'dataviz-unity-mcp' and not re.search(r'(?:^|[/\\])unity(?:\.exe)? mcp(?: |$)', str(health.get('cli', ''))): + return False + verify_docker_host_route(port, host, health.get('instanceId', '')) + return True + except (OSError, ValueError): + return False + + +def persist_port(workspace: Path, port: int) -> bool: + path = workspace / '.env.local' + try: + content = path.read_bytes().decode('utf-8') + except OSError: + content = '# Machine-local credentials (gitignored).\n' + line = f'UNITY_MCP_HTTP_PORT={port}' + existing = re.search(r'^UNITY_MCP_HTTP_PORT=.*$', content, re.MULTILINE) + if existing: + if existing.group().strip() == line: + return False + content = content[:existing.start()] + line + content[existing.end():] + elif port != 9020: + content = content.rstrip('\n') + '\n\n# Auto-persisted Unity MCP bridge port\n' + line + '\n' + else: + return False + try: + path.write_bytes(content.encode('utf-8')) + return True + except OSError as error: + log('[unity-mcp] Could not persist port to .env.local: ' + str(error), True) + return False + + +def serve(workspace: Path, settings: BridgeSettings, preferred: int, host: str, done: threading.Event) -> int: + if existing_bridge(preferred, host): + log(f'[unity-mcp] A healthy unity-mcp bridge is already running on port {preferred}; nothing to do.') + return 0 + for port in range(preferred, min(preferred + 100, 65535) + 1): + if not port_free(port, host): + log(f'[unity-mcp] Port {port} is busy; trying another port.') + continue + bridge = Bridge(settings) + server = BridgeServer(host, port, bridge) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + hostname = '127.0.0.1' if host == '0.0.0.0' else '::1' if host == '::' else host + try: + if read_health(port, hostname).get('instanceId') != bridge.instance: + raise ValueError('the loopback endpoint did not answer as this bridge instance') + except (OSError, ValueError) as error: + raise ValueError('Local HTTP self-check failed: ' + str(error)) from error + log(f'[unity-mcp] Local HTTP self-check passed (bridge PID {os.getpid()}, instance {bridge.instance}).') + verify_docker_host_route(port, host, bridge.instance) + log(f"[unity-mcp] Official Unity CLI MCP bridge: {bridge.health()['cli']}") + log(f'[unity-mcp] HTTP MCP server listening on http://{host}:{port}/mcp') + log(f'[unity-mcp] Health: http://{host}:{port}/healthz') + log('[unity-mcp] Auth: ' + ('bearer token required' if settings['token'] else 'OPEN (no UNITY_MCP_TOKEN configured)')) + if persist_port(workspace, port): + log(f'[unity-mcp] Persisted UNITY_MCP_HTTP_PORT={port} to .env.local so client configs follow.') + if settings['autoSync'] == '0': + log('[unity-mcp] AUTO_SYNC disabled; run `npm run mcp:sync` to update MCP clients.') + else: + log('[unity-mcp] Syncing MCP client configs across agentic frontends…') + try: + result = subprocess.run([sys.executable, str(workspace / 'scripts' / 'mcp_configure.py')], cwd=workspace, + env={**os.environ, 'UNITY_MCP_HTTP_PORT': str(port)}, timeout=30, check=False) + if result.returncode: + raise ValueError('configurator failed') + log('[unity-mcp] MCP client configs updated (unity URL follows the new port).') + except (OSError, ValueError, subprocess.TimeoutExpired): + log('[unity-mcp] Config sync failed; run `npm run mcp:sync` manually.') + log('[unity-mcp] Startup complete.') + done.wait() + return 0 + except DockerHostRouteError as error: + log(str(error), True) + finally: + server.shutdown() + server.server_close() + bridge.close() + raise ValueError(f'No verified bridge port is available in {preferred}-{min(preferred + 100, 65535)}. Remove VS Code reverse tunnels or check Docker Desktop networking.') + + +def main(arguments: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--host', default='0.0.0.0') + parser.add_argument('--port') + options = parser.parse_args(arguments) + settings = resolve_settings(ROOT, Path.home(), os.environ) + for warning in settings['warnings']: + log(warning, True) + try: + value = float(options.port if options.port is not None else settings['port']) + if not value.is_integer() or not 1 <= value <= 65535: + raise ValueError('invalid port') + except ValueError: + log('--port must be an integer between 1 and 65535.', True) + return 1 + done = threading.Event() + def stop(signum: int, frame: Optional[FrameType]) -> None: + done.set() + + previous = {code: signal.signal(code, stop) for code in (signal.SIGINT, signal.SIGTERM)} + try: + return serve(ROOT, settings, int(value), options.host, done) + except (OSError, ValueError) as error: + log('[unity-mcp] ' + str(error), True) + return 1 + finally: + for code, handler in previous.items(): + signal.signal(code, handler) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/unity_mcp_host.py.meta b/scripts/unity_mcp_host.py.meta new file mode 100644 index 0000000..1ccded6 --- /dev/null +++ b/scripts/unity_mcp_host.py.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 26ca55fe38fb42cfb8a46e8b724f6fc7 From 1fd531f83ee3211b43fafea8c56b6c6e0b0bd760 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sun, 18 Oct 2026 21:07:16 +0000 Subject: [PATCH 2/3] Keep Windows bridge fixtures on the base interpreter Windows virtual-environment python.exe redirects to a separate interpreter process. The fixtures compared the redirector PID with the bridge PID. Use the base interpreter for both fixture processes so process identity assertions keep checking the actual server. All three failing cases pass in a fresh native Windows venv. Strict pyright, the affected fast check, and the complete 17-step gate pass. The repair changes tests only. Refs #171. --- scripts/tests/test_unity_mcp_host.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test_unity_mcp_host.py b/scripts/tests/test_unity_mcp_host.py index f792f62..2e34e97 100644 --- a/scripts/tests/test_unity_mcp_host.py +++ b/scripts/tests/test_unity_mcp_host.py @@ -23,6 +23,7 @@ sys.dont_write_bytecode = True REPO = Path(__file__).resolve().parents[2] +PYTHON = str(getattr(sys, "_base_executable", sys.executable)) def cleanup_fixture_directory(path: Path) -> None: @@ -227,7 +228,7 @@ def setUp(self) -> None: self.env = {key: value for key, value in os.environ.items() if not key.startswith('UNITY_')} self.env.update(HOME=str(self.home), USERPROFILE=str(self.home), UNITY_MCP_AUTO_SYNC='0') self.env_file = self.root / '.env.local' - self.env_file.write_text(f'\ufeffexport UNITY_CLI="{sys.executable}"\nUNITY_MCP_PROJECT="{self.project}"\nUNITY_MCP_TOKEN=fixture-secret\nUNITY_MCP_TIMEOUT_MS=1500\n', encoding='utf-8') + 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] = [] @@ -265,7 +266,7 @@ def launch(self, command: Optional[list[str]] = None) -> None: time.sleep(0.02) def command(self) -> list[str]: - launcher = [sys.executable] if self.python_bridge else [str(shutil.which('node')), *self.node_options] + 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)] def logs(self) -> str: From 094f3a7b0263fe1fcab48851ad8b2a3c2b3fef64 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sun, 18 Oct 2026 21:26:08 +0000 Subject: [PATCH 3/3] Preserve request and port ownership during bridge failures A failed spawn could remove the previous request's pending entry before the new request acquired an ID. Remove only the ID acquired by the failing request. Add a regression that keeps the earlier waiter reachable until CLI failure completes it. Windows SO_REUSEADDR can bind beside an active listener. Use default Windows binding for both probes and the HTTP server; retain Unix TIME_WAIT reuse. libuv uses the same Windows policy because SO_EXCLUSIVEADDRUSE also rejects TIME_WAIT sockets: https://github.com/libuv/libuv/blob/v1.x/src/win/tcp.c Both regressions fail before repair. Native Windows passes all seven startup unit cases, including the occupied reusable listener; all 14 shared Python host cases complete with four expected platform/legacy skips. Strict pyright and the final 17-step gate pass. Refs #171 and PR #299 review findings. --- scripts/tests/test_unity_mcp_host_python.py | 32 +++++++++++++++++++++ scripts/unity_mcp_host.py | 9 ++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test_unity_mcp_host_python.py b/scripts/tests/test_unity_mcp_host_python.py index f4b74ed..852689f 100644 --- a/scripts/tests/test_unity_mcp_host_python.py +++ b/scripts/tests/test_unity_mcp_host_python.py @@ -4,6 +4,7 @@ from pathlib import Path import socket +import os import sys import threading import unittest @@ -84,6 +85,37 @@ def health(port: int, hostname: str) -> host.JsonMap: server.shutdown.assert_called_once() server.server_close.assert_called_once() + def test_spawn_failure_preserves_an_earlier_inflight_request(self) -> None: + bridge = host.Bridge(settings()) + self.addCleanup(bridge.close) + earlier = host.Pending('earlier') + bridge.next_id = 9 + bridge.pending[9] = earlier + with patch.object(bridge, 'ensure_child', side_effect=ValueError('spawn cooldown')): + result = bridge.request({'id': 'later', 'method': 'test'}) + self.assertEqual(result['id'], 'later') + self.assertIn('spawn cooldown', result['error']['message']) + self.assertIs(bridge.pending.get(9), earlier) + self.assertFalse(earlier.ready.is_set()) + bridge.fail_pending('CLI exited') + self.assertTrue(earlier.ready.is_set()) + self.assertEqual(earlier.response, host.rpc_error('earlier', -32000, 'CLI exited')) + + @unittest.skipUnless(os.name == 'nt', 'Windows socket reuse semantics') + def test_reusable_windows_listener_is_not_available_to_probe_or_server(self) -> None: + with socket.socket() as occupant: + occupant.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + occupant.bind(('127.0.0.1', 0)) + occupant.listen(8) + port = occupant.getsockname()[1] + self.assertFalse(host.port_free(port, '127.0.0.1')) + self.assertFalse(host.port_free(port, '0.0.0.0')) + bridge = host.Bridge(settings()) + self.addCleanup(bridge.close) + with self.assertRaises(OSError): + with host.BridgeServer('127.0.0.1', port, bridge): + pass + def test_invalid_json_and_batch_notifications_keep_protocol_responses(self) -> None: bridge = host.Bridge(settings()) self.addCleanup(bridge.close) diff --git a/scripts/unity_mcp_host.py b/scripts/unity_mcp_host.py index 61af41d..4ab822e 100644 --- a/scripts/unity_mcp_host.py +++ b/scripts/unity_mcp_host.py @@ -141,6 +141,7 @@ def write(self, message: JsonMap) -> None: def request(self, message: JsonMap) -> JsonMap: with self.lock: + identifier: Optional[int] = None try: self.ensure_child() self.next_id += 1 @@ -149,7 +150,8 @@ def request(self, message: JsonMap) -> JsonMap: self.pending[identifier] = pending self.write({**message, 'id': identifier}) except (OSError, ValueError) as error: - self.pending.pop(self.next_id, None) + if identifier is not None: + self.pending.pop(identifier, None) return rpc_error(message['id'], -32000, str(error)) timeout = float(self.settings['timeout']) / 1000 if not pending.ready.wait(timeout): @@ -204,6 +206,7 @@ def close(self) -> None: class BridgeServer(ThreadingHTTPServer): + allow_reuse_address = sys.platform != 'win32' daemon_threads = True bridge: Bridge @@ -310,7 +313,9 @@ def port_free(port: int, host: str) -> bool: for address in (host, '127.0.0.1') if host in ('0.0.0.0', '::', 'localhost') else (host,): try: with socket.socket(socket.AF_INET6 if ':' in address else socket.AF_INET) as probe: - probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # Windows reuse can claim an active port; default binding also permits TIME_WAIT reuse. + if sys.platform != 'win32': + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) probe.bind((address, port)) except OSError: return False