Safety model
Giving an agent a Mac is a trust decision. macos-mcp is built so that the decision is bounded, inspectable and reversible — in code that ships, not in a policy document.
Hard lines
Some things the server will not do under any profile, for any caller, with any token:
- Secure text fields are never read and never typed into.
ui_snapshotmarks themsecureand omits their value;ui_typeinto one returns a typedrefusedcarrying analternative, not a best-effort attempt. - No Keychain, no passwords. Nothing in the tool surface touches credential storage.
- No system authentication dialogs. The server never fills in or clicks through a macOS auth prompt.
- No TCC circumvention. Permissions are asked for through System Settings, never seeded, spoofed or bypassed — that includes the Automation prompt, which only a human on that Mac's screen can answer.
Two things that widen the blast radius, and how they are fenced
The ios toolset and macos-mcp vm both extend an agent's reach past the Mac in front of it. Both are opt-in, and neither loosens anything above.
- A mirrored iPhone is off by default. The
iostoolset is not in the default set, so an agent cannot touch a phone until somebody switches it on withenable_toolsetor--toolsets. Every verb that acts on the phone is declared destructive, so understandardeach one needs a confirmation token bound to that exact call, and every call is audited like any other. It also needs both grants — Screen Recording to see the phone, Accessibility to touch it — and it can only reach a phone the person at the Mac has already mirrored, with the same Apple Account, locked and nearby. The server never answers the Mac's own authentication prompt when a paused session asks for one:iphone_resumereportsauthentication_requiredand stops. - A VM is where risky work belongs.
macos-mcp vmexists so an agent can be given a Mac nobody minds losing. It is a CLI, not a tool an agent can call, and the hard line above holds inside the guest too: the one-time TCC grant an image needs stops at the macOS password dialog and hands off to a human. Completing it hands-free is an explicit opt-in flag, meant for an operator's own disposable image and nobody else's Mac.
Permission profiles
| Profile | What it allows |
|---|---|
readonly | Only tools annotated read-only: snapshots, screenshots, OCR, reads of the clipboard, calendar, contacts, tabs. Everything else is refused with a typed error naming the profile. |
standard (default) | Everything except raw applescript_eval. Tools annotated destructive need a confirmation token (below). |
full | Trusts the caller: destructive tools run directly and raw AppleScript/JXA is allowed. |
The profile is enforced in the one dispatcher every tool passes through, so mounted Apple tools (web_*, xcode_*) obey it through their wire annotations without any per-tool code.
Confirmation tokens
Under standard, a destructive call — trash, app_quit, applescript_eval, a mounted tool that declares destructiveHint — does not run. It returns confirmation_required with a one-shot token bound to that exact call: the same tool, the same arguments. Re-issue the call with the token and it runs once; the token is validated, consumed and stripped before the tool sees it, and it authorizes nothing else. An agent that wants to delete something has to say so twice, in the same words.
Clients that support MCP elicitation get the same handshake as a user-facing prompt.
Verify, never trust the return code
This is the property most macOS automation gets wrong. Accessibility writes (AXUIElementSetAttributeValue) and synthetic events (CGEvent) both report success while doing nothing — a field that ignores the value, an event dropped by an untrusted process, a click delivered to a window that was covered. macos-mcp reads every effect back:
ui_typere-reads the field; a write that changed nothing isaction_unverified.window_managereads the window state back and reports the frame the window actually took after clamping.clipboard_writereads the pasteboard back.app_quitconfirms the process is gone.trashchecks both sides of the move.tagsreads the tags back.ui_clickrefuses a target covered by another app's window — with the covering app's name — rather than clicking through onto the wrong window.- Requests that could only be no-ops are refused up front: a zero-delta scroll, a click on a disabled control, a reveal on an element that cannot scroll.
Where there is genuinely no read-back — a drag's effect is app-specific — the result says verified: false and tells the agent to re-snapshot. Honesty about uncertainty beats a confident lie.
Typed, self-healing errors
Failures are a closed set. Every error carries a stable code — permission_missing, element_not_found, invalid_argument, refused, action_unverified, and the rest listed in the tool reference — plus what an agent needs to recover: the missing grant and its System Settings deep-link, the element ids visible right now, what the element actually offers. Prose-only errors are banned by test.
Audit log
Every call appends a line to ~/.macos-mcp/audit.jsonl: timestamp, caller, tool, outcome, error code — and a SHA-256 digest of the arguments, never the arguments themselves, because typed text can be a password or a private message. The file rotates; macos-mcp audit tail and macos-mcp audit summary read it back without a JSON tool.
A frozen tool API
Tool names and argument shapes are public API. The surface is snapshotted in tool-schemas.json and every build is diffed against the last release: a removed or renamed tool, a removed or newly required argument, a narrowed enum, or a tool that quietly becomes destructive fails the build unless the deprecation ledger announced it at least one minor version earlier. Nothing you write against this server should break because a name was tidied.
No telemetry
There is no analytics, no crash reporting, no update check and no network call inside the binary. The only network traffic is what a tool you invoke does, such as opening a URL. There is no surface for telemetry to be added quietly: the audit log is local and the code is MIT.
Supply chain
Releases are a Developer ID-signed, Apple-notarized binary published on GitHub Releases with SHA-256 sums. The installer verifies the checksum and the signature chain before installing and refuses anything that fails either check. A stable signing identity also means your TCC grants survive updates — an advantage native binaries have over Python and Node servers, whose grants attach to the interpreter.
Reporting a vulnerability
Use GitHub private vulnerability reporting on the repository; the security policy lists what is in scope — profile bypasses, confirmation-token or audit evasion, any hard-line crossing, installer or release integrity — and the response targets.