Skip to main content
Glama

apple-debug-mcp

An MCP debug daemon for Apple Simulators: multiple AI agent sessions share one Mac's simulator pool while the daemon tracks who holds which simulator (leases) and auto-dispatches freed simulators to the next waiter in line. It runs locally as a single shared daemon over Streamable HTTP, built on simslim for simulator lifecycle (boot/shutdown/slim, low-RAM slim boots) and AXe for UI automation.

Not affiliated with Apple. This is an independent community project. Apple, Xcode, Simulator, and Instruments are trademarks of Apple Inc., used here only to describe what this software connects to.

Agent sessions (Orca / Claude Code / TMA / scripts)
        │  Streamable HTTP (POST /mcp), one MCP connection per session
        ▼
apple-debug-mcp daemon ── shared state: leases + wait queue + auto-dispatch
        │  child processes
        ├── simslim ── boot/shutdown/slim, pool state
        └── axe ────── lease holders only

Features

  • 65 tools across 10 groups — pool/lease/queue, AXe UI automation, device extras, synthetic camera, app lifecycle, xctrace profiling, live streaming, llmdb debugging, crash symbolication, and an HTTPS intercept proxy

  • One shared daemon — leases, the wait queue, and auto-dispatch live in a single process; per-session stdio servers would split the pool

  • Lease ownership gates — each lease holds one simulator and only its holder may drive UI, apps, debugging, or the proxy through it

  • Wait queue with auto-dispatch — when no simulator matches, callers get a ticket; releases, TTL expiry, and external-shutdown detection hand the next match to the first fitting ticket (FIFO)

  • Optional dependencies degrade gracefully — serve-sim, llmdb, and mitmdump are probed at boot; the daemon starts without them and only the tools that need them fail, with install hints

  • Operator-safe CLI — doctor is read-only, launchd restart refuses while leases are held, hand-edited plists are never overwritten silently

  • Single version source — package.json feeds /health, the MCP handshake (serverInfo), and the server field of pool_status alike

Related MCP server: Simulator MCP

Prerequisites

  • macOS + Xcode (Simulator runtime)

  • Node.js 18 or later

  • simslim and axe:

brew install mobai-app/tap/simslim
brew install cameroncooke/axe/axe

Optional, enabling only the tools named:

npm install -g serve-sim          # sim_rotate, sim_ca_debug, sim_memory_warning, camera_*, stream_*
brew install alexmx/tools/llmdb   # debug_*
brew install mitmproxy            # net_proxy_*, net_assert_flow, map_local, map_remote

Install

From a checkout of this repo, five steps:

1. npm install
2. npm run build
3. npm install -g .   # recommended: the launchd plist points at the stable npm-global bin (~/.npm-global/bin)
4. apple-debug-mcp doctor            # read-only check; every failure prints its exact fix
5. apple-debug-mcp launchd create    # write the plist + load it

Then register the daemon URL in your clients and verify it answers:

apple-debug-mcp install --client all   # Claude Code / Codex / Muse (user scope)
curl http://127.0.0.1:3100/health      # {"ok":true,"version":"...","gitSha":"...",...}
tail -f ~/.apple-debug-mcp/daemon.log

Defaults (fixed):

  • Daemon path: the npm-global bin (~/.npm-global/bin/apple-debug-mcp) first, falling back to this checkout's dist/index.js with a loud warning. SIM_MCP_DAEMON_PATH overrides both.

  • Log: ~/.apple-debug-mcp/daemon.log, stdout and stderr combined.

  • State: ~/.apple-debug-mcp/state.json (leases/queue), lockfile daemon.lock beside it.

  • install writes user scope only. Project scope could point sessions at different daemons and split the pool — one shared daemon is the premise.

  • The CLI never pings the npm registry. The local package.json is the only version source.

Quick start

Check the daemon is up, then take a lease:

apple-debug-mcp launchd status
{ "session_id": "<your session id>", "client": "claude-code", "task": "log in and screenshot" }

Call lease_acquire with that body. With spare capacity you get a lease immediately; otherwise you get a wait ticket (ticket_id, position) — poll queue_status until it shows status: "assigned" with a lease_id. Drive the simulator with the ui_* tools, renew long work with lease_heartbeat, and finish with lease_release, which instantly dispatches the freed simulator to the first matching ticket.

Configuration

install writes Streamable-HTTP URL entries only. Shapes verified against the real configs on macOS:

{
  "mcpServers": {
    "apple-debug": { "type": "http", "url": "http://127.0.0.1:3100/mcp" }
  }
}

Claude Code (user scope, ~/.claude.json → mcpServers); manual equivalent:

claude mcp add -s user --transport http apple-debug http://127.0.0.1:3100/mcp

Codex (~/.codex/config.toml):

[mcp_servers.apple-debug]
url = "http://127.0.0.1:3100/mcp"

Muse (~/.config/muse/settings.json → mcpServers):

"apple-debug": { "type": "streamable-http", "url": "http://127.0.0.1:3100/mcp" }

Preview or inspect without writing:

apple-debug-mcp install --client codex,muse --dry-run  # preview the change
apple-debug-mcp install --client all --status          # registration status only

Unknown client names fail as "Unsupported" without touching any file. Every edited config is backed up to <config>.bak-sim-mcp first (created once, never overwritten).

Environment variables (all optional):

Variable

Default

Description

SIM_MCP_HOST / SIM_MCP_PORT

127.0.0.1 / 3100

Listen address

SIM_MCP_STATE_FILE

~/.apple-debug-mcp/state.json

Lease/queue persistence file

SIMSLIM_BIN / AXE_BIN

simslim / axe

Binary paths

SERVE_SIM_BIN

serve-sim

serve-sim binary path (optional dependency)

LLMDB_BIN

llmdb

llmdb binary path (optional dependency)

MITMDUMP_BIN

mitmdump

mitmdump binary path (optional dependency)

SERVE_SIM_PORT_BASE

3220

First port scanned for per-lease stream helpers

NETPROXY_PORT_BASE

3240

First port scanned for the lease-held proxy listener

SIM_MCP_MAX_SIMS

0 (uncapped)

Cap on concurrently booted simulators; excess waits

SIM_MCP_LEASE_TTL_MS

1800000 (30 min)

Lease survival without a heartbeat

SIM_MCP_SWEEP_MS

60000 (60 s)

Lease/queue sweep interval

SIM_MCP_EXEC_TIMEOUT_MS

60000 (60 s)

Helper child-process timeout

SIM_MCP_BOOT_TIMEOUT_MS

600000 (10 min)

Simulator boot timeout

SIM_MCP_SCREENSHOT_DIR

$TMPDIR/sim-mcp-shots

Screenshot storage

SIM_MCP_DAEMON_PATH

(unset)

Override the daemon entry the plist launches

Upgrade

git pull
npm install
npm run build
npm install -g .   # when the launchd plist points at the npm-global bin
apple-debug-mcp launchd restart        # refused while leases are held; --force overrides
apple-debug-mcp launchd status         # loaded + /health + version

Re-running launchd create regenerates the plist while preserving its port/host. A hand-edited plist shows a diff and is never overwritten without --force.

Uninstall

apple-debug-mcp install --client all --remove  # unregister from client configs
apple-debug-mcp launchd remove                 # unload + delete the plist
npm uninstall -g apple-debug-mcp               # when installed via npm-global

Tools

65 tools across 10 groups. Tools marked * require a lease_id and accept only the holding lease.

Group

Tools

Pool/state (5)

pool_status, sessions_list, sim_boot, sim_shutdown, sim_slim

Lease/queue (7)

lease_acquire, lease_release, lease_heartbeat, lease_list, queue_status, queue_cancel, lease_actions

UI automation (8) *

ui_describe, ui_tap, ui_type, ui_swipe, ui_gesture, ui_button, ui_screenshot, ui_batch

Device extras (5)

sim_rotate, sim_ca_debug, sim_memory_warning, sim_log_stream, sim_log_show

Synthetic camera (5)

camera_start, camera_switch, camera_mirror, camera_status, camera_stop

App lifecycle (5) *

app_open_url, app_install, app_uninstall, app_launch, app_terminate

Profiling (1) *

profile_trace

Live streaming (2) *

stream_start, stream_stop

Debugging (20) *

debug_attach, debug_launch, debug_break, debug_continue, debug_run_until, debug_wait, debug_interrupt, debug_bt, debug_threads, debug_locals, debug_scopes, debug_expand, debug_eval, debug_set_var, debug_output, debug_step, debug_detach, debug_sessions, debug_doctor, symbolicate (lease optional)

Network proxy (7) *

net_proxy_start, net_proxy_set_rules, net_proxy_stop, net_proxy_status, net_assert_flow, map_local, map_remote

Key behaviors:

  • lease_acquire: matches conditions (udid / device substring / osVersion prefix / platform), auto-boots when needed, auto-slims stock simulators (--no-reboot, custom profiles untouched), else enqueues a ticket.

  • lease_release, TTL expiry, and external-shutdown detection auto-dispatch the freed simulator to the first matching ticket (FIFO; auto_boot tickets may boot one). Release does not shut the simulator down — it hands over booted; call sim_shutdown separately to stop it.

  • sim_shutdown refuses on a held simulator unless force=true; rebooting sim_slim, sim_rotate, sim_ca_debug, and sim_memory_warning behave the same, naming the holder in the error. camera_start/camera_switch are lease-gated the same way; camera_mirror/camera_status/camera_stop ignore leases.

  • ui_screenshot returns a PNG path plus an image block. sim_rotate takes portrait|portrait_upside_down|landscape_left|landscape_right; sim_ca_debug takes option=blended|copies|misaligned|offscreen|slow-animations plus an enabled boolean.

  • sim_log_stream collects live logs for a window (default 5s, max 60s), sim_log_show reads the past --last window (default 5m); both are xcrun simctl spawn-based, so they need no serve-sim. Results are {log, truncated, timedOut} — stream always ends on window expiry, so timedOut: true is normal.

  • Log filters: a full predicate (predicate, e.g. process == "MyApp") or the process shorthand; both combine with AND. Predicates are rejected before the call when empty or unbalanced. Quote string literals with double quotes (eventMessage CONTAINS "x"); parens inside literals are ignored, so process == "My(App)" is valid.

  • Output past max_chars (default 20000) keeps the tail with a …[truncated N chars] prefix. Prefer a narrow window (timeout_sec/last) plus a process filter.

  • App lifecycle is xcrun simctl-based with no extra dependencies: app_launch returns {pid}; terminate fails on a non-running app ("found nothing to terminate"); uninstall quietly succeeds on a missing bundle (idempotent). Every call is recorded in lease_actions.

  • profile_trace records with xcrun xctrace record --device into a .trace file (default template "Time Profiler", default 5s, max 60s). --attach often cannot find the simulator process ("Cannot find process"), so whole-system recording is the default. Note: on this host family simulator-target recording stalls at "Starting recording..." (host recording works), so the smoke test asserts the validation path only.

  • Streaming is opt-in: lease_acquire with stream: true (a stream failure only warns; the lease stands) or stream_start/stream_stop (both idempotent; stream_start returns the existing URL when already streaming). The URL lives on the lease's streamUrl, visible in pool_status/sessions_list/lease_list. One stream costs one serve-sim helper process plus MJPEG CPU — turn it on to look, off when done; ui_screenshot is cheaper for stills. Ports scan upward from SERVE_SIM_PORT_BASE because serve-sim kills whatever occupies its requested port instead of scanning (and its :3100 default would collide with this daemon).

  • Debugging wraps the llmdb CLI (an lldb-dap wrapper with session JSON): debug_attach {bundle_id|pid} (exactly one) or debug_launch {path, args?} opens one session per lease, then break/continue/inspect. Needs a debuggable (get-task-allow) third-party build plus one-time host developer-tools authorization — stock Apple apps refuse attach. llmdb 0.2.0 takes file:line breakpoints only (no symbol breakpoints), expand takes a variablesReference number (not an expression), and launch takes a binary path (not a bundle ID).

  • symbolicate is atos-based host-local crash symbolication: lease_id is optional (validated and logged when given), with no udid/force gates. binary is effectively required (no live-pid mode).

  • Network proxy: net_proxy_start {lease_id, rules?} intercepts the lease simulator's HTTPS (mitmdump + generated rewrite addon + mitm CA into the simulator keychain); net_proxy_set_rules swaps rules (restarts only mitmdump, same port); net_proxy_stop is idempotent and restores the host proxy snapshot; net_assert_flow polls the holder's flow log (JSONL). Rules are match{url_regex?, method?} → action{status?, headers?, body?, delay_ms?, request?, map_local?, map_remote?}, first match wins, max 50; omitted match matches everything, rules: [] is transparent intercept-and-log. See Security for the host-wide warning and consent boundary.

Usage examples

"Boot a simulator, hold it, and tap through login."

{ "session_id": "sess-1", "client": "claude-code", "task": "login flow", "device": "iPhone 16" }
{ "lease_id": "<from lease_acquire>" }
{ "lease_id": "<lease>", "label": "Continue" }

That is lease_acquire, then ui_describe, then ui_tap on the named element (label matches the accessibility label; ui_button is for hardware buttons only). Finish with lease_release.

"Record a 10-second Time Profiler trace of the app under test."

{ "lease_id": "<lease>", "duration_sec": 10 }

That is profile_trace; the result carries the .trace path.

"Rewrite one API response to a fixture file while the test runs."

{ "lease_id": "<lease>", "rules": [{ "match": { "url_regex": "api\\.example\\.com/v1/feed" }, "action": { "map_local": "/tmp/feed.json" } }] }
{ "lease_id": "<lease>", "url_regex": "api\\.example\\.com/v1/feed", "min_count": 1 }

That is net_proxy_start, then net_assert_flow to prove the app hit the endpoint. Stop with net_proxy_stop as soon as the test is done.

Instructions for AI agents

  • There must be exactly one shared daemon. Never start a per-session stdio server — the pool and queue would not be shared. If /health is unreachable, say so instead of working around it with direct simctl.

  • Pass your tool's session ID as session_id with a client label so sessions_list shows who runs where. The server does not enforce the format.

  • A wait ticket is normal. Poll queue_status; do not boot simulators out of band to skip the line.

  • Only the holding lease may call that lease's gated tools. On a holder-mismatch error, stop and report the holder — do not retry with force=true unless the user asked for preemption.

  • Renew long work with lease_heartbeat before the TTL (default 30 min) expires; expired leases are reclaimed and redispatched.

  • Release with lease_release when done. Release hands the simulator over booted — shut it down with sim_shutdown only if the task needs it stopped.

  • Turn the proxy and streams off when done: net_proxy_stop, stream_stop. Both cost host-wide resources while on.

Troubleshooting

Start with doctor — every failure prints its exact fix:

apple-debug-mcp doctor
apple-debug-mcp launchd status   # plist present? loaded? /health reachable (which version)?
tail -f ~/.apple-debug-mcp/daemon.log

Common failures:

  • /health unreachable but the plist is loaded: the daemon crashed at boot — the log shows why (missing simslim/axe is the usual cause).

  • Port conflict: the daemon exits 10 when the port is taken, 11 when another daemon holds the lockfile (generic failures exit 1). Restart via apple-debug-mcp launchd restart, never by launching a second daemon.

  • launchd restart refuses with active leases: wait for release or re-run with --force (drops in-flight work).

  • Hand-edited plist: create/remove show a diff and refuse without --force.

  • install reports "already installed" on the second run — that is the idempotent success path, not an error.

  • Optional-tool failure naming serve-sim, llmdb, or mitmdump: install that binary (see Prerequisites); the daemon runs fine without it otherwise.

  • debug_attach refused: the build needs get-task-allow, the host needs its one-time developer-tools approval, and stock Apple apps always refuse.

  • Host proxy left on after a daemon crash (SIGKILL skips cleanup): kill mitmdump and switch every service off by hand:

pkill -f "mitmdump -p "
networksetup -listallnetworkservices | tail -n +2 | while IFS= read -r svc; do
  svc="${svc#\*}"   # strip the disabled marker
  sudo networksetup -setwebproxystate "$svc" off
  sudo networksetup -setsecurewebproxystate "$svc" off
done

Normal shutdown (SIGINT/SIGTERM), lease_release, TTL expiry, sweeps, and external-shutdown detection all restore the snapshot automatically.

Security

  • Lease-gated mutation. UI, apps, profiling, streaming, debugging, and the proxy all require the holding lease_id; cross-lease calls fail naming the holder. Only one proxy holder exists across all leases.

  • sudo is narrow and explicit. Host proxy control needs passwordless sudo for exactly four networksetup subcommands — -setwebproxy, -setsecurewebproxy, -setwebproxystate, -setsecurewebproxystate — always as sudo -n (never interactive). Reads (-listallnetworkservices, -getwebproxy/-getsecurewebproxy) need no sudo. Nothing is checked or changed at daemon boot, because every -set* mutates host state; net_proxy_start fails with a sudoers-setup hint when authorization is missing.

  • Rules validate before any host change. A bad rule is rejected without touching host networking.

  • Host-wide proxy warning. While the proxy is on, all host web traffic (including other apps' background traffic) flows through mitmdump, and intranet sites on a private CA may break until stop/restore. Keep it on only as long as the test needs. The mitm CA stays in the simulator keychain after stop (harmless without the proxy; full removal is xcrun simctl keychain <udid> reset).

  • No silent destructive actions. doctor is read-only (no --fix); restart refuses under active leases; hand-edited plists need --force; local-only mode exists because process-scoped interception (mitmdump --mode local:…) needs a manually approved macOS System Extension and is unverifiable in unattended environments, so only host mode ships.

  • Localhost only by default. The daemon binds 127.0.0.1:3100; the MCP endpoint has no authentication, so do not rebind it to a shared interface.

What this is not

This is a local, single-host debug daemon. It manages simulators on the Mac it runs on, keeps its state in a local JSON file, and serves MCP over unauthenticated localhost HTTP. It is not a multi-host device farm, not a CI XCTest runner, and not a general macOS automation server — the tools cover the simulator debug loop (boot, lease, drive UI, profile, debug, intercept) and project their results down to what a model can usefully read.

It is also not a simulator runtime itself. Booting, slimming, and UI driving delegate to simslim, AXe, serve-sim, llmdb, and the Xcode toolchain; when those drift, doctor and the weekly CI smoke run are the early warning.

Development

Requires Node.js 18 or later, macOS + Xcode for anything past unit tests.

npm install
npm test        # unit tests (orchestrator/wrappers/store/CLI/launchd/doctor/install/…)
npm run typecheck
npm run smoke   # live-simulator E2E (daemon boot → boot → queue → dispatch → shutdown)
npm run dev     # tsx hot run

Command

What it does

npm test

Runs the suite. This is the one to run.

npm run typecheck

Types only, no emit.

npm run build

Compile to dist/.

npm run smoke

Live E2E against real simulators. Needs macOS + Xcode + simslim/axe.

CI has two jobs: check (Linux, every PR/push: typecheck + unit tests + build) and smoke-macos (main pushes, weekly Mondays, manual dispatch, and PRs labeled run-smoke: installs simslim/axe via brew and runs the live smoke to catch external CLI/Xcode drift early). Dependabot opens weekly npm and GitHub Actions update PRs.

Layout: src/index.ts (HTTP daemon/session map) · src/server.ts (MCP tool definitions) · src/orchestrator.ts (acquire/release/dispatch) · src/store.ts (JSON persistence) · src/simslim.ts · src/axe.ts · src/servesim.ts · src/simlog.ts · src/simapps.ts · src/xctrace.ts · src/llmdb.ts · src/symbolicate.ts · src/netproxy.ts · src/extras.ts · src/exec.ts · src/cli.ts · src/version.ts · src/singleton.ts · src/launchd.ts · src/doctor.ts · src/install.ts · src/migrate.ts · test/

(A Korean translation of this README is welcome later — contributions accepted.)

Licence

MIT — see the license field in package.json.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    High-performance MCP server for iOS development and test automation. Gives AI coding assistants direct access to iOS simulators with sub-20ms screenshots, UI interaction, building, testing, and an intelligent operator mode.
    8
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables AI to control iOS simulators through the MCP protocol. Supports device management, UI automation, and network interception including screenshot capture, text input, and HTTP request mocking.
    -
  • A
    license
    Not graded
    quality
    F
    maintenance
    Enables AI agents to control real iPhones and simulators on macOS, allowing for UI interaction, testing, and automation.
    56 npm
    91
    MIT