Skip to content

Cli

cli

AXM MCP CLI — Lifecycle management for the MCP server.

Subcommands

serve Start the Streamable HTTP server. status Check whether the server is running. stop Send SIGTERM to the running server.

Running axm-mcp with no subcommand preserves backward-compatible stdio mode.

install(*, port=DEFAULT_PORT, binary=None)

Install the MCP server as a launchd service.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
@app.command
def install(
    *,
    port: Annotated[int, cyclopts.Parameter(help="Server port.")] = DEFAULT_PORT,
    binary: Annotated[
        Path | None,
        cyclopts.Parameter(help="Explicit binary path for the plist."),
    ] = None,
) -> None:
    """Install the MCP server as a launchd service."""
    from axm_mcp import lifecycle

    lifecycle.install(port, binary=binary)

is_axm_mcp_process(pid)

Return True only if pid's command line identifies an axm-mcp server.

Guards against OS PID reuse: an existence probe (:func:is_process_alive) cannot tell our server apart from an unrelated process that inherited the same PID. We inspect the target's command line (no psutil dependency) and require the axm-mcp marker. Any failure (process vanished, read error, mismatch) yields False — we never send SIGTERM on an unconfirmed identity.

/proc/<pid>/cmdline is preferred where it exists (Linux): it yields the full NUL-separated argv, whereas ps -o command= renders a column the kernel may truncate for a long argv — an absolute venv path plus arguments is long enough to lose the trailing marker, which silently turns a live server into an "unverified" one. ps stays the fallback for platforms without /proc (macOS).

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
def is_axm_mcp_process(pid: int) -> bool:
    """Return True only if *pid*'s command line identifies an axm-mcp server.

    Guards against OS PID reuse: an existence probe (:func:`is_process_alive`)
    cannot tell our server apart from an unrelated process that inherited the
    same PID. We inspect the target's command line (no ``psutil`` dependency)
    and require the ``axm-mcp`` marker. Any failure (process vanished, read
    error, mismatch) yields False — we never send SIGTERM on an unconfirmed
    identity.

    ``/proc/<pid>/cmdline`` is preferred where it exists (Linux): it yields the
    full NUL-separated argv, whereas ``ps -o command=`` renders a column the
    kernel may truncate for a long argv — an absolute venv path plus arguments
    is long enough to lose the trailing marker, which silently turns a live
    server into an "unverified" one. ``ps`` stays the fallback for platforms
    without ``/proc`` (macOS).
    """
    try:
        raw = Path(f"/proc/{pid}/cmdline").read_bytes()
    except OSError:
        pass  # No /proc (macOS), or the process vanished — fall back to ps.
    else:
        return AXM_MCP_MARKER in raw.decode("utf-8", "replace")

    try:
        result = subprocess.run(  # noqa: S603
            ["ps", "-p", str(pid), "-o", "command="],  # noqa: S607
            capture_output=True,
            text=True,
            timeout=3,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return False
    if result.returncode != 0:
        return False
    return AXM_MCP_MARKER in result.stdout

is_process_alive(pid)

Check whether a process with pid is running.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
def is_process_alive(pid: int) -> bool:
    """Check whether a process with *pid* is running."""
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    return True

main()

Entry point for axm-mcp command.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
def main() -> None:
    """Entry point for ``axm-mcp`` command."""
    app()

read_pid()

Read PID from file, returning None if absent or invalid.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
def read_pid() -> int | None:
    """Read PID from file, returning None if absent or invalid."""
    pid_file = _active_pid_file()
    if not pid_file.exists():
        return None
    try:
        return int(pid_file.read_text().strip())
    except (ValueError, OSError):
        return None

remove_pid_file()

Remove PID file if it exists.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
def remove_pid_file() -> None:
    """Remove PID file if it exists."""
    _active_pid_file().unlink(missing_ok=True)

serve(*, host='127.0.0.1', port=DEFAULT_PORT, shared=None)

Start the MCP server with Streamable HTTP transport.

The PID file is transactional: a second serve refuses to start when a live axm-mcp server already owns it (avoids clobbering the survivor's PID with a doomed instance that then fails the bind), and the finally only removes the file when it still contains our PID — so a failed start does not delete the legitimate server's PID file.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
@app.command
def serve(
    *,
    host: Annotated[str, cyclopts.Parameter(help="Bind address.")] = "127.0.0.1",
    port: Annotated[int, cyclopts.Parameter(help="Bind port.")] = DEFAULT_PORT,
    shared: Annotated[
        bool | None, cyclopts.Parameter(help="Require per-session write contracts.")
    ] = None,
) -> None:
    """Start the MCP server with Streamable HTTP transport.

    The PID file is transactional: a second ``serve`` refuses to start when a
    live axm-mcp server already owns it (avoids clobbering the survivor's PID
    with a doomed instance that then fails the bind), and the ``finally`` only
    removes the file when it still contains *our* PID — so a failed start does
    not delete the legitimate server's PID file.
    """
    explicit_mode = None if shared is None else ("shared" if shared else "dedicated")
    try:
        serve_mode = resolve_serve_mode(explicit_mode)
    except ValueError as exc:
        print(f"Invalid serve mode: {exc}", file=sys.stderr)  # noqa: T201
        raise SystemExit(1) from exc

    shared_mode = serve_mode == "shared"
    if shared is True:
        print(  # noqa: T201
            "Shared mode is unavailable on stdio because it has no session identity.",
            file=sys.stderr,
        )
        raise SystemExit(1)

    if shared_mode:
        os.environ["AXM_MCP_SHARED"] = "1"
    else:
        os.environ.pop("AXM_MCP_SHARED", None)

    from axm_mcp import mcp_app as _mcp_app
    from axm_mcp import server as _server

    session_resolver = _mcp_app._resolve_session_contract if shared_mode else None

    existing = read_pid()
    if (
        existing is not None
        and is_process_alive(existing)
        and is_axm_mcp_process(existing)
    ):
        print(  # noqa: T201
            f"Refusing to start: an axm-mcp server is already running "
            f"(PID {existing}). Use 'axm-mcp stop' first.",
            file=sys.stderr,
        )
        raise SystemExit(1)

    own_pid = os.getpid()
    write_pid(own_pid)
    try:
        if shared_mode:
            _server.serve(
                host=host,
                port=port,
                shared=True,
                session_resolver=session_resolver,
            )
        else:
            _server.serve(host=host, port=port)
    finally:
        if read_pid() == own_pid:
            remove_pid_file()

status(*, host='127.0.0.1', port=DEFAULT_PORT)

Check whether the MCP server is running.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
@app.command
def status(
    *,
    host: Annotated[str, cyclopts.Parameter(help="Server host.")] = "127.0.0.1",
    port: Annotated[int, cyclopts.Parameter(help="Server port.")] = DEFAULT_PORT,
) -> None:
    """Check whether the MCP server is running."""
    url = f"http://{host}:{port}/health"
    try:
        resp = httpx.get(url, timeout=3)
    except httpx.HTTPError as err:
        # Any transport-level failure (connect refused/timeout, read timeout,
        # malformed response) means "not reachable" — never a raw traceback.
        print("Server not running", file=sys.stderr)  # noqa: T201
        raise SystemExit(1) from err

    if resp.status_code != httpx.codes.OK:
        print(f"Server responded with status {resp.status_code}", file=sys.stderr)  # noqa: T201
        raise SystemExit(1)
    try:
        tools = resp.json().get("tools_count", "?")
    except ValueError:  # non-JSON body (JSONDecodeError subclasses ValueError)
        tools = "?"
    print(f"Server running on {host}:{port} ({tools} tools)")  # noqa: T201

stop()

Stop the running MCP server.

Before sending SIGTERM, the target PID's identity is verified against the axm-mcp command-line marker. If the PID has been reused by an unrelated process (or vanished), the signal is NOT sent: the stale PID file is removed and the command exits non-zero.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
@app.command
def stop() -> None:
    """Stop the running MCP server.

    Before sending SIGTERM, the target PID's identity is verified against the
    ``axm-mcp`` command-line marker. If the PID has been reused by an unrelated
    process (or vanished), the signal is NOT sent: the stale PID file is
    removed and the command exits non-zero.
    """
    pid = read_pid()

    if pid is None:
        print("Server not running (no PID file)", file=sys.stderr)  # noqa: T201
        raise SystemExit(1)

    if not is_process_alive(pid):
        remove_pid_file()
        print("Server not running (stale PID file cleaned up)", file=sys.stderr)  # noqa: T201
        raise SystemExit(1)

    if not is_axm_mcp_process(pid):
        remove_pid_file()
        print(  # noqa: T201
            f"Refusing to stop: PID {pid} is not an axm-mcp process "
            "(reused or vanished); stale PID file cleaned up",
            file=sys.stderr,
        )
        raise SystemExit(1)

    os.kill(pid, signal.SIGTERM)
    remove_pid_file()
    print(f"Sent SIGTERM to server (PID {pid})")  # noqa: T201

uninstall()

Uninstall the launchd service.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
@app.command
def uninstall() -> None:
    """Uninstall the launchd service."""
    from axm_mcp import lifecycle

    lifecycle.uninstall()

write_pid(pid)

Write PID file, creating parent directory if needed.

Source code in packages/axm-mcp/src/axm_mcp/cli.py
Python
def write_pid(pid: int) -> None:
    """Write PID file, creating parent directory if needed."""
    pid_file = _active_pid_file()
    pid_file.parent.mkdir(parents=True, exist_ok=True)
    pid_file.write_text(str(pid))