diff --git a/src/macos_mcp/__main__.py b/src/macos_mcp/__main__.py index dd4a891..7305926 100644 --- a/src/macos_mcp/__main__.py +++ b/src/macos_mcp/__main__.py @@ -45,6 +45,7 @@ import signal import subprocess import sys +import time from threading import Lock import click @@ -964,6 +965,58 @@ def _launchctl(*args: str) -> subprocess.CompletedProcess: return subprocess.run(["launchctl", *args], capture_output=True, text=True) +def _launchctl_field(output: str, field: str) -> str | None: + prefix = f"{field} = " + for line in output.splitlines(): + stripped = line.strip() + if stripped.startswith(prefix): + return stripped[len(prefix) :] + return None + + +def _server_accepting_connections(host: str, port: int) -> bool: + probe_host = {"0.0.0.0": "127.0.0.1", "::": "::1"}.get(host, host) + try: + with socket.create_connection((probe_host, port), timeout=0.1): + return True + except OSError: + return False + + +def _wait_for_launch_agent_start( + domain: str, host: str, port: int, timeout: float = 1.0 +) -> tuple[bool, str]: + """Briefly verify that a freshly bootstrapped agent did not immediately exit.""" + deadline = time.monotonic() + timeout + saw_running = False + + while True: + result = _launchctl("print", f"{domain}/{_AGENT_LABEL}") + if result.returncode != 0: + detail = result.stderr.strip() or "launchctl print could not find the service" + return False, detail + + state = _launchctl_field(result.stdout, "state") + last_exit = _launchctl_field(result.stdout, "last exit code") + last_signal = _launchctl_field(result.stdout, "last terminating signal") + if last_exit and last_exit != "(never exited)": + return False, f"state={state or 'unknown'}, last exit code={last_exit}" + if last_signal: + return False, f"state={state or 'unknown'}, last terminating signal={last_signal}" + if state == "running": + saw_running = True + if _server_accepting_connections(host, port): + return True, "accepting connections" + + if time.monotonic() >= deadline: + break + time.sleep(0.1) + + if saw_running: + return True, "process is running; endpoint is still starting" + return False, "launch agent did not reach a running state" + + @main.command() @click.option( "--transport", @@ -1008,7 +1061,14 @@ def install(transport: str, host: str, port: int, force: bool) -> None: if result.returncode != 0: raise click.ClickException(f"launchctl bootstrap failed:\n{result.stderr.strip()}") - click.echo(f"Launch agent loaded — server is starting now.") + started, detail = _wait_for_launch_agent_start(domain, host, port) + if not started: + raise click.ClickException( + "Launch agent loaded but the server failed to stay running " + f"({detail}).\nCheck {CONFIG_DIR / 'server.error.log'} for startup errors." + ) + + click.echo(f"Launch agent loaded — {detail}.") click.echo(f" Transport : {transport}") click.echo(f" Address : {host}:{port}") click.echo(f" Logs : {CONFIG_DIR / 'server.log'}") diff --git a/tests/test_launchd_install.py b/tests/test_launchd_install.py new file mode 100644 index 0000000..894bbfe --- /dev/null +++ b/tests/test_launchd_install.py @@ -0,0 +1,125 @@ +from types import SimpleNamespace + +import macos_mcp.__main__ as server + + +def _completed(returncode=0, stdout="", stderr=""): + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +def test_launchctl_field_parses_launchd_status(): + output = """ + state = not running + runs = 1 + last exit code = 78: EX_CONFIG + """ + + assert server._launchctl_field(output, "state") == "not running" + assert server._launchctl_field(output, "last exit code") == "78: EX_CONFIG" + + +def test_wait_for_launch_agent_reports_immediate_exit(mocker): + mocker.patch.object(server, "_server_accepting_connections", return_value=False) + mocker.patch.object( + server, + "_launchctl", + return_value=_completed( + stdout="state = not running\nlast exit code = 78: EX_CONFIG\n" + ), + ) + + started, detail = server._wait_for_launch_agent_start( + "gui/501", "127.0.0.1", 8000, timeout=0 + ) + + assert started is False + assert detail == "state=not running, last exit code=78: EX_CONFIG" + + +def test_wait_for_launch_agent_reports_signal_termination(mocker): + mocker.patch.object(server, "_server_accepting_connections", return_value=False) + mocker.patch.object( + server, + "_launchctl", + return_value=_completed( + stdout="state = not running\nlast terminating signal = Terminated: 15\n" + ), + ) + + started, detail = server._wait_for_launch_agent_start( + "gui/501", "127.0.0.1", 8000, timeout=0 + ) + + assert started is False + assert detail == "state=not running, last terminating signal=Terminated: 15" + + +def test_wait_for_launch_agent_rejects_restart_after_failed_exit(mocker): + mocker.patch.object(server, "_server_accepting_connections", return_value=False) + mocker.patch.object( + server, + "_launchctl", + return_value=_completed( + stdout="state = running\nlast exit code = 78: EX_CONFIG\n" + ), + ) + + started, detail = server._wait_for_launch_agent_start( + "gui/501", "127.0.0.1", 8000, timeout=0 + ) + + assert started is False + assert detail == "state=running, last exit code=78: EX_CONFIG" + + +def test_wait_for_launch_agent_accepts_running_process_during_slow_start(mocker): + mocker.patch.object(server, "_server_accepting_connections", return_value=False) + mocker.patch.object( + server, + "_launchctl", + return_value=_completed(stdout="state = running\nlast exit code = (never exited)\n"), + ) + + started, detail = server._wait_for_launch_agent_start( + "gui/501", "127.0.0.1", 8000, timeout=0 + ) + + assert started is True + assert detail == "process is running; endpoint is still starting" + + +def test_wait_for_launch_agent_accepts_listening_running_agent(mocker): + mocker.patch.object(server, "_server_accepting_connections", return_value=True) + mocker.patch.object( + server, + "_launchctl", + return_value=_completed(stdout="state = running\nlast exit code = (never exited)\n"), + ) + + started, detail = server._wait_for_launch_agent_start( + "gui/501", "127.0.0.1", 8000, timeout=0 + ) + + assert started is True + assert detail == "accepting connections" + + +def test_wait_for_launch_agent_does_not_accept_unrelated_listener(mocker): + listener = mocker.patch.object( + server, "_server_accepting_connections", return_value=True + ) + mocker.patch.object( + server, + "_launchctl", + return_value=_completed( + stdout="state = not running\nlast exit code = 78: EX_CONFIG\n" + ), + ) + + started, detail = server._wait_for_launch_agent_start( + "gui/501", "127.0.0.1", 8000, timeout=0 + ) + + assert started is False + assert detail == "state=not running, last exit code=78: EX_CONFIG" + listener.assert_not_called()