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..2e34e97 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 @@ -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: @@ -126,21 +127,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 +204,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 +228,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="{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 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 +266,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 = [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: self.log.flush() @@ -283,7 +298,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 +368,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 +380,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 +496,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 +510,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 +566,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 +604,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 +622,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..852689f --- /dev/null +++ b/scripts/tests/test_unity_mcp_host_python.py @@ -0,0 +1,133 @@ +#!/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 os +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_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) + 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..4ab822e --- /dev/null +++ b/scripts/unity_mcp_host.py @@ -0,0 +1,442 @@ +#!/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: + identifier: Optional[int] = None + 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: + 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): + 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): + allow_reuse_address = sys.platform != 'win32' + 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: + # 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 + 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