Introducing macos-mcp: give your AI agent a Mac — and a way to check its work

2026-09-09 · by Ark0N

Today macos-mcp v0.2.0 goes public: an MIT-licensed, Developer ID-signed macOS MCP server that gives any MCP client the whole Mac — and, unlike everything else in the category, checks that each action actually happened.

The problem with “done”

If you have let an agent drive a Mac, you have seen it: the model says it filled in the field, the screenshot shows the field empty, and the model is not lying — macOS told it the write succeeded. Accessibility writes return success for values the app ignored. Synthetic events return success when they were dropped, or when they landed on the window covering the target. Every macOS automation server we looked at trusts those return codes, and every agent built on them inherits the blind spot.

macos-mcp's first rule is verify, never trust the return code. ui_type re-reads the field. window_manage reads the frame the window actually took. clipboard_write reads the pasteboard back. app_quit waits for the process to be gone. ui_click refuses a covered target and names the app covering it. A write that changed nothing comes back as a typed action_unverified error, and the one verb with no honest read-back — a drag — says verified: false and tells the agent to look again.

What is in the box

One Swift binary, 44 tools in eight namespaced toolsets, 24 of them on by default so the tool list stays short enough for models to choose well:

Alongside them, not among them: macos-mcp vm, a CLI that clones and destroys disposable macOS guests on this host. It is deliberately not a toolset — more on that below.

Every result is structured JSON. Every failure is one of a closed set of codes and carries what the agent needs to recover: the missing permission and its System Settings deep-link, the element ids visible right now, what the element actually offers. The reference is generated from the compiled schemas, so it cannot drift.

The false success we caught on real glass

The ios toolset works from outside the iPhone Mirroring window, and that window is a strange object. The mirrored content is an opaque video surface with no accessibility tree at all, so this is the one place where the accessibility-first doctrine is deliberately inverted: iphone_find — ScreenCaptureKit, Vision OCR, fuzzy match — is the primary locator rather than a fallback. Everything around the phone stays accessibility-first, because the app's own chrome is fully exposed, and that chrome is the only thing that can tell a paused session from a locked one from a signed-out one. Pixels cannot: a lock screen is still phone-shaped.

The first time iphone_key back ran against a phone in the room, it reported verified: true. It had done nothing to the phone.

The back key is iOS's own left-edge swipe, and the swipe started at device x = 1 — a point that lands on the iPhone Mirroring window's own border rather than on the glass. macOS read it as a grab on the window. The window resized, the after-capture differed from the before-capture, and the read-back — the thing this entire project exists to do — reported that difference as an effect on the phone. It verified against its own side effect.

Read-back is the thesis, and here it caught the project's own bug. It only could because it was pointed at real hardware: every synthetic fixture stayed green through the whole incident, because a fixture window does not resize when you press its edge.

The fix is a measured inset rather than a guessed one. Every posted point is now pushed clear of the window frame, and the margin is 16 pt because 8 was not enough — the unit test written from the incident showed the press that resized the window sitting 8.8 pt inside the frame. A guessed constant would have shipped 8 pt and looked correct. iphone_tap and iphone_swipe run caller-supplied points through the same inset, because a press on a window's border was never what “tap the phone here” meant, and the result body reports the adjustment when one happens, so the coordinate you asked for never silently disagrees with where the event went.

Two results came out of it. The same call on a screen where nothing happens now answers action_unverified — “nothing changed in 3001 ms, 0.0% of the screen moved” — which is the honest answer, and is exactly where the old code returned a green check. And on 2026-09-08, back verified for real: the swipe started at device x = 9.355, precisely 16 pt inside the window's left edge, 81.08% of the screen changed after 120 ms, and the screenshot showed Today View — the widgets page iOS itself opens for a left-edge swipe on the Home screen.

All five keys are measured on real glass: home (⌘1), app_switcher (⌘2), spotlight (⌘3), lock (⌘W, which ends the session) and back. One of them cannot be verified and says so: iphone_type reports verification.method: "none", because iOS hides the on-screen keyboard while mirroring and nothing on the Mac can read the field back. Branch on that, not on ok, and read the screen with iphone_screenshot when it matters. It is the same rule as everywhere else, applied to the case where the answer is “we cannot tell”.

A Mac the agent is allowed to destroy

The safest place for an agent to do risky work is a Mac you can throw away. As of v0.2.0 that Mac exists, on a single host:

macos-mcp vm clone golden-27 scratch   # APFS clonefile, fresh machine identifier
macos-mcp vm start scratch             # a window in this Mac's console session
macos-mcp vm verify scratch            # acceptance, with nobody in the guest
macos-mcp vm destroy scratch           # gone

vm is a CLI subcommand group and deliberately not a toolset: the tool surface is frozen and diffed on every build, and disposable machines are an operator's job rather than a call an agent makes mid-task. Underneath is Apple's Virtualization.framework directly, behind the same VMDriver protocol the fleet design was built on.

A clone is an APFS clonefile, so it is instant and costs no disk until it diverges, with a regenerated VZMacMachineIdentifier and its own MAC address and auxiliary storage — the two things that must never be shared. Both are verified by reading them back, because the read-back rule is not just for UI tools.

Two facts shape what you can do with this, and neither is a design choice.

Two macOS VMs per Apple Silicon host is a kernel quota, and it is measured. On macOS 27 beta 4 the third VM is refused with VZErrorDomain code 6 while 39% of RAM is still free. That is worth stating precisely, because it is the kind of number people assume is a conservative default someone picked: it is not. Buying more RAM does not raise it. Fleet capacity is hosts × 2, and the only answer to wanting more is more hosts — which is why the multi-host layer exists as a design at all.

A VZ guest renders nothing without a view in a logged-in session. Headless, locked, and lost-WindowServer all produce an all-zero framebuffer, so a running VM is a window in the console user's session, captured host-side. A locked host blacks out every VM on it — a global kill switch you get for free and a host profile you have to mean: never lock, never sleep.

Then there is TCC, which is the part everyone gets wrong. Accessibility and Screen Recording cannot be granted from outside, and TCC guards consent against synthetic input on purpose. So the grant is solved once per golden image and inherited by every clone. vm grant orchestrates everything around that one physical click — boot the image, provoke the entries, open the pane, say exactly what to click, then probe fresh ssh sessions until both grants read back, and record them in the manifest so clones inherit them. vm verify is the per-clone acceptance, and it needs no human in the guest: an accessibility read, a keystroke, a screencapture and a non-black host frame, all proven from a fresh ssh session with nobody logged into the VM.

The product default stops at the macOS password dialog and hands off to a person. Automating a system authentication dialog is one of this project's hard lines and it does not move for convenience. On an operator's own disposable image there is an opt-in — vm grant --drive --password — which completes both Privacy panes hands-free and verifies the result against the guest's own TCC database rather than against a dialog it just clicked. That is an opt-in on a machine you are about to delete, not the default on yours.

Single host, today. The hub that routes work across many hosts is in development. Disposable Macs for agents →

Safety you can point at

“Should I let an agent do this?” deserves a better answer than a disclaimer. So:

Proof, not claims

Every build prints its own report card: macos-mcp eval run executes a bundled suite of agent tasks against the real server — fill a form, find text without accessibility, refuse a secure field, save a document — and scores it, with skips reported and never counted. With --driver claude a real model solves the same tasks through the server. The published number at /evals/latest.json is that run, and the endpoint refuses to publish anything that is not a genuine complete run: a logged-out agent CLI and a half-skipped suite have both tried to become a score here, and both were turned away. Measured 2026-09-06, on a permission-granted Mac on the macOS 27 beta, it reads 100% on 5/5 agent-driven tasks as the mean of three runs, and 100% on 9/9 scripted tasks. That is a dated measurement, not a nightly one; it will be re-run on macOS 27 GA as soon as it ships, and the date on the page will move with it.

Apple's MCPs, and the gap around them

Apple now ships MCP servers for Safari and Xcode. They are good, and macos-mcp does not reimplement a line of them — it mounts them at boot, re-exports their tools under the same namespaces, and puts the same permission profile over them. Safari works today: where Apple's server is available its 17 tools come through verbatim as web_*, and where it is not, an AppleScript fallback fills the same slot and labels every result method: "fallback" so the agent always knows which engine answered. What Apple does not ship is an MCP for the Mac itself — and with macOS 27 Golden Gate arriving Apple Silicon-only this month, that is exactly the machine agents are about to get a lot more of. macOS 27 and agents →

What is not shipped

Two things, said plainly, because a launch post that only lists wins is not worth reading.

Try it

curl -fsSL https://macos-mcp.sh | sh
macos-mcp install --client claude-code   # or claude-desktop, cursor, vscode, codex
macos-mcp doctor

The installer verifies the checksum and Apple signature before installing and fails closed on either. Then ask your agent to snapshot the frontmost app and click Save, and watch it work off the accessibility tree instead of guessing at pixels.

The code is at github.com/Ark0N/macos-mcp. Issues, discussions and pull requests are open; the security policy says what to report privately. If it says “done”, it checked.