Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/macos_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import signal
import subprocess
import sys
import time
from threading import Lock
import click

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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'}")
Expand Down
125 changes: 125 additions & 0 deletions tests/test_launchd_install.py
Original file line number Diff line number Diff line change
@@ -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()