computer-use-mcp

A single signed Swift binary that turns your Mac into a standard MCP server — so any agent (Claude Code, Cursor, Codex, your own) can see and operate your apps in the background, without hijacking your cursor or stealing focus.
Swift 6 · macOS 14+ MCP over stdio Accessibility + ScreenCaptureKit + CoreGraphics Shared per-user daemon ~13k LOC 17 tools

This doc is a map, not a tour of every line. It gives you the shape of the system and the load-bearing ideas, with file:path anchors so you can dive where you want. Read §1–4 first for the skeleton; the rest is depth on each moving part.

01What it is

Most "computer use" runs against a sandboxed VM or a remote framebuffer: the agent gets one composited pixel stream and a virtual mouse. This project is different — it's a local, trusted, in-session driver. Because it runs on your real Mac as you, it can reach below the pixels:

Accessibility (AX)

Semantic tree of every window: roles, labels, values, actions, focused element, settable attributes. This is how it "reads" UIs precisely instead of guessing from pixels.

ScreenCaptureKit

Captures one specific window — even occluded or on another Space — without foregrounding it. Not a whole-display stream.

CoreGraphics event posting

Posts mouse/key events to a process or window (CGEventPostToPid) instead of always moving a global pointer.

A shared local daemon

One engine process per user coordinates capture, input, the cursor overlay, and app leases across every concurrent agent session.
The tradeIt's more capable than a VM driver, but correctness now depends on macOS TCC grants (Accessibility + Screen Recording), the target app's AX/event behaviour, and an unlocked desktop. The whole design is about being honest about that seam rather than faking success.

02The big idea: two operators, no focus fight

The central promise — the thing every subsystem exists to protect — is background control. You keep typing in your foreground app while an agent drives a different app that may be occluded behind other windows. The agent's synthetic clicks and keystrokes never move your real cursor and never steal keyboard focus.

Three principles fall out of that promise, and they're the lens for everything below:

Background-safe by default

Every mutating tool tries the path that doesn't touch the real cursor or foreground first. Focus-changing paths exist but need explicit opt-in (allow_focus_change, allow_global_cursor).

Never lie about success

Background event delivery has no OS "it landed" signal. So the server observes the effect and reports a verdict — it will not claim success it can't see.

Yield to the human

If you're actively working in the same app, mutating tools pause with a recoverable error rather than interleaving synthetic events with your real ones.

03Process model

One binary, launched as different subcommands (see main.swift). Understanding the three-process split is the single most important thing about this codebase.

Claude CodeCursorCodex CLIGemini CLIyour agent
│ spawn one serve each, talk MCP over stdio
serve  (N of them — one per client)
A thin stdio shim. Lists tools, forwards every CallTool to the daemon over a Unix socket. Holds no engine state. Serve.swift · Dispatch.swift
│ Unix domain socket · 0700 dir · same-UID peer check · bearer token
daemon  (exactly one per user — flock singleton)
The engine. Owns Accessibility, ScreenCaptureKit, input delivery, snapshots, app leases. All sessions funnel through here, so two agents cannot collide on shared system services. Idle-exits after 30 min → next session spawns a fresh one (also picks up binary updates). Daemon/DaemonServer.swift
│ writes target points to a FIFO
overlay  (one singleton helper)
Cosmetic agent cursor. A separate .accessory AppKit process (the server has no run loop). Draws a glyph gliding to targets across all displays. Never moves the real cursor. Overlay/OverlayHelper.swift

Why a shared daemon?

  • No collisions by construction. Capture, AX, and input are single-owner. Multiple agents can't stomp each other's screenshots or event streams.
  • Cross-process element ids. Snapshots persist to disk, so an element_id minted in one process resolves in another (serve, the call harness, or a restarted server).
  • App leases. The daemon serializes action tools against the same resolved pid for a short window — two sessions can't interleave actions in one app. Perception is never blocked. Daemon/AppLeases.swift
Fail-closed ruleEvery tool call crosses the authenticated daemon boundary. If the daemon is unreachable, both read-only and mutating tools return structured DAEMON_UNAVAILABLE errors; neither runs in-process. The daemon alone calls the local dispatch funnel and persists buffered runtime metrics.

Other subcommands are operational: doctor (permission check/prompt), health_report (identity + permissions + capture + daemon diagnostics), call (invoke one tool from the shell — the dev/self-test harness), version.

04A request's journey

Every entry point (serve, daemon, call) converges on one dispatch funnel, dispatchTool() in Dispatch.swift. That single funnel is why guarantees are uniform — nothing reaches a handler without passing the same gates.

// client → serve → daemon → dispatchTool()
1. catalog lookup            // unknown tool → error
2. RateLimiter.acquire()     // optional global throttle (off by default)
3. SleepAssertion.noteActivity()  // keep the Mac awake while calls flow
4. preflightRefusal():
      ├─ screen-lock pause     // mutating tools pause when locked
      ├─ interference yield    // yield if the human is active in this app
      └─ URL policy            // browser deny/confirm patterns
5. spec.handler(arguments)   // the actual tool logic
6. log + Telemetry.record    // per-call stderr line + counters

At the daemon level there are two more gates in front of this: peer auth (same-UID + bearer token, DaemonServer.swift:239) and app-lease check before dispatch. The result comes back carrying _meta blocks — focus telemetry and the outcome verdict (§7) — that generic MCP clients can read to detect regressions.

05Perception & snapshots — live AX identity

How the agent "sees". get_app_state is the canonical surface: it returns one window's AX tree + a ScreenCaptureKit screenshot, with every element boxed in the screenshot's pixel space. Tools/Perception.swift · Core/Screenshot.swift

The stale-reference problem, solved with exact handles

The daemon gives the model an element_id backed by the exact live AXUIElement captured for that node. Before an action, it proves the handle is live, compares its semantic fingerprint, checks the owning PID, and proves attachment to the captured window. Core/Snapshot.swift · Core/Target.swift

The id-survival contractCore Foundation equality and hashing let the same live AX object keep its id across captures, even when its tree position or screenshot scale changes. A recreated or detached object becomes stale; the daemon asks for fresh state instead of searching for a similar replacement.

Three coordinate spaces (name which one, always)

SpaceUsed byOrigin / units
Screenshot pixelsstate boxes, OCR boxes, click/scroll/drag coordsTop-left of latest window screenshot, pixels
Global screen pointsAX frames, internal delivery, manage_window movemacOS global display space, top-left, points
Window-local pointswindow-routed synthetic events (internal bridge)Bottom-left AppKit window coords

Coordinate actions require a prior snapshot (it carries window origin, scale, pixel bounds). Out-of-bounds coordinates are rejected, not clipped. Core/HitTest.swift

Cheap loops: diffs, not re-sends

After a mutating action, the result returns reduced fresh state. If the tree didn't change, it says "existing ids remain valid" instead of resending. If it did, it returns a compact diff — changed (~), appeared (+), disappeared () — falling back to the full tree only when >50% churned. For sparse UIs, use ocr:true; for truncated trees, scope get_app_state to a retained container. Core/TreeBuilder.swift · Core/OCR.swift

06The dispatch ladder — how it acts without stealing focus

This is the heart of the background guarantee. For any click/scroll/key, the server tries delivery paths AX-first, event-last, and — critically — never auto-escalates past tier 3, because macOS gives no reliable "event landed" signal. Core/Input.swift

Tier 1 · AX
AXPress, AXShowMenu, set value, select text, menu/window attributes. Precise, semantic, posts no event at all. Fails loudly when unsupported (disabled control, no settable value).
Tier 2 · per-window
When the AX window maps to a CGWindowID, bridge an NSEvent with that windowNumber and CGEventPostToPid. Routed to a specific window, no cursor movement.
Tier 2.5 · SkyLight
Flagged prototype, off by default. Private SLEventPostToPid SPI, dlopen-resolved at runtime. May break across macOS versions / notarization. COMPUTER_USE_MCP_SKYLIGHT=1.
Tier 3 · per-pid
No window id available → CGEventPostToPid to the process directly. Still background, still no cursor movement. App-dependent: apps needing real key-focus may drop it.
Tier 4 · global
Explicit escape hatch only. Real cursor warp + session tap. Clicks need allow_global_cursor:true; keyboard additionally needs the app already foreground. Restores the cursor afterward.
Why no auto-escalationTiers 2–3 have no success signal. Rather than silently take over your session, a tier-2/3 delivery that produced no visible UI change appends a dropped-event hint naming the explicit retry (allow_global_cursor:true + allow_focus_change:true). The caller decides. AX-tier actions fail loudly and get no hint.

07Verifier-first outcomes

The design pivot that makes background control trustworthy: move mutating tools from "success = the AX call didn't throw" to "success = the effect was observed." Core/ActionOutcome.swift · docs/outcome-contract.md

Every mutating tool snapshots the target element's fields before dispatch, re-reads them (plus the whole-window change bit) after, diffs the fields that matter for that action family, and reduces to a four-value verdict carried in a computer-use-mcp/outcome _meta block:

VerdictMeaning
successEffect observed — or the target was already in the requested state (idempotent no-op counts).
effect_not_verifiedDispatched cleanly but no confirming effect seen — the exact case old contracts wrongly called success.
verifier_ambiguousCouldn't read enough state to judge. Never a hard error — it may well have worked.
unsupportedThe target can't perform this action at all (disabled, no settable value). Retrying won't help.

Note isError is left unchanged — a verified no-effect is not a throw, it's data. This composes with the focus telemetry block (focus_changed, delivery_tier, ui_changed, frontmost before/after) from Core/FocusTelemetry.swift. A generic client treating focus_changed:true + focus_change_allowed:false as a regression is the intended contract.

08Safety gates the agent can't bypass

These run server-side inside the funnel — no tool argument disables them casually. Core/SafetyPolicy.swift · InterferenceGuard.swift · URLPolicy.swift · SessionPower.swift

Interference yield

If real HID input was seen within interference_idle_seconds (default 1s) and the target app is the one you're in — or the action uses the global cursor/keyboard — the call returns a recoverable yield error. Read from CGEventSource HID state, which the server's own synthetic per-pid/window events don't update, so the agent can't trip its own guard.

Browser URL policy

Before a mutating action on a known browser, it reads the current page URL from AX. url_deny substrings block outright (confirm can't override); url_confirm + built-in payment-page defaults require per-action confirm. Unreadable URL fails closed only if you configured patterns.

Screen lock & sleep

A prevent-idle-sleep assertion is held while calls flow (released after a quiet period) so long tasks don't die to sleep. When the screen is actually locked, mutating tools pause with a recoverable error; read-only perception stays live. No auto-unlock — that's your call.

Confirmation gating

Destructive labels, confirmation-listed apps, secure password fields, risky URL schemes, and destructive window actions all require confirm:true. The caller reads the reason and retries deliberately.

09The agent cursor overlay

Purely cosmetic, but it's what makes background control legible to a watching human: you can see where the agent is working without it touching your real pointer. Overlay/OverlayHelper.swift · AgentCursor.swift

  • Separate process because the MCP server is a headless async stdio process with no AppKit run loop. The helper runs an .accessory NSApplication — no Dock icon, never frontmost.
  • Singleton — one cursor serves every concurrent server process. Reads target points from a shared FIFO (move <globalX> <globalY>, ping keep-alives) and glides a glyph toward them.
  • Multi-display correct. One click-through borderless panel per display (because "Displays have separate Spaces" clips a window to one screen). The cursor position is modeled once in AppKit global coords and mirrored into every panel; each clips to its own screen, so the glyph appears on whichever display holds it — even straddling a boundary mid-glide.
Verification gotchaA plain screencapture shell-out cannot see this overlay. To prove the cursor renders, capture via ScreenCaptureKit through the real server.

10Tool catalog — 17 tools

Schemas + model-facing descriptions live in Tools/Catalog.swift; handlers in the sibling Tools/*.swift. Mutating tools (red) pass the full gate stack and fail closed without a daemon; read-only tools (green) can fall back in-process.

mutating — gated, needs daemon read-only — perception, never blocked

Perception (read-only)

get_app_statelist_appslist_windowsread_clipboardhealth_report

Interaction (mutating)

clicktype_textset_valuepress_keyscrolldragselect_textperform_secondary_actionwrite_clipboard

Apps, windows & navigation (mutating)

open_appopen_urlmanage_window

Exact mutating set: click, type_text, set_value, press_key, scroll, drag, select_text, perform_secondary_action, write_clipboard, open_app, open_url, manage_window — see ToolKit/ToolSpec.swift (mutatingToolNames).

11Source map — where to look

Everything is under Sources/computer-use-mcp/. Four layers:

LayerFilesWhat lives here
Entry / wiringmain.swift · Serve.swift · Dispatch.swift · Call.swift · Doctor.swift · HealthReport.swift · Version.swiftSubcommand routing, the shared dispatch funnel, rate limiter, per-call logging.
ToolKitToolKit/ToolSpec.swift · Schema.swiftTool-spec type, mutating-tool set, typed argument accessors, JSON schema helpers.
Tools (the public surface)Tools/Catalog.swift · Perception.swift · ClickTool.swift · InputTools.swift · TextTools.swift · SystemTools.swift · Handlers.swift · HealthReportTool.swiftSchemas + handlers for the focused 17-tool surface.
Core (the engine)Core/Snapshot.swift · TreeBuilder.swift · AX.swift · Input.swift · SkyLightInput.swift · Screenshot.swift · HitTest.swift · ActionOutcome.swift · FocusTelemetry.swift · MetricsRecorder.swift · InterferenceGuard.swift · URLPolicy.swift · SafetyPolicy.swift · SessionPower.swift · Window.swift · WindowMotion.swift · ScrollRouting.swift · AppResolver.swift · Target.swift · Keymap.swift · OCR.swift · GeometrySanitization.swift · Telemetry.swift · Config.swift · CrossProcessLock.swift · ErrorCodes.swift · PointTarget.swiftPerception, the dispatch ladder, verification, safety, coordinate math, telemetry. This is the real substance of the project.
DaemonDaemon/DaemonServer.swift · DaemonClient.swift · DaemonProtocol.swift · AppLeases.swiftSocket server, auth handshake, framing/limits, per-app leases.
OverlayOverlay/OverlayHelper.swift · AgentCursor.swift · OverlayTransport.swiftThe cosmetic agent-cursor helper process + FIFO transport.

Plus: Sources/ComputerUseFixture/ — a deterministic AppKit GUI fixture with stable AX identifiers, for the end-to-end "truth suite"; Tests/ComputerUseMCPTests/; scripts/ (live/CI eval harnesses like live_background_eval.py); docs/ (the contracts below); packaging/homebrew/.

12The contracts — read these next

This repo is unusually spec-driven: the behaviour is written down as contracts that tests, tool docs, and evals all reference. If you read nothing else after this page, read these two.

Modality Contract

The production bible. A surface-by-surface matrix (observation, capture scope, element identity, dispatch ladder, leases, safety) with default contract + escape hatch + failure language for each. Every claim ends with a source anchor.

Background Control Contract

The three delivery modes (background-safe / best-effort / focus-mutating), the focus-telemetry JSON shape, the yield-to-human rule, and the deterministic eval recipe.

Outcome Contract

The verifier design + per-tool "trap matrix" behind §7.

TESTING.md

The three verification tiers and the hosted-CI-vs-live-GUI safety boundary.

Fixture App

The deterministic GUI used as the source of truth for background-control checks.

13Mental model recap

  1. One binary, three roles. N thin serve shims → one daemon engine → one overlay cursor. The daemon owning all shared state is why concurrent agents can't collide.
  2. One funnel. Every call — from any entry point — passes the same gates in dispatchTool. Uniform guarantees come from that single chokepoint.
  3. See below the pixels. AX tree for semantics + ScreenCaptureKit for one window + ids backed by validated live AX handles. Same objects retain ids; recreated objects fail stale.
  4. Act AX-first, event-last, never auto-escalate. The ladder protects your cursor and focus by default; global takeover is always an explicit opt-in.
  5. Never lie. Background delivery has no OS success signal, so the server verifies the effect and reports a four-value verdict + focus telemetry instead of claiming success.
  6. Yield to the human. Interference guard, screen-lock pause, URL policy, confirmation gating — all server-side, all recoverable errors, none the agent can casually disable.
Where to poke nextTell me a thread you want to pull — e.g. "walk me through a click end to end", "how does live-handle validation work in Snapshot.swift", "show me the daemon auth handshake", or "how does scoped state preserve identity" — and we'll go deep from here.