@sitharaj88/macctl
v0.1.0
Published
macOS desktop-automation MCP server — accessibility-tree-first control of windows, input, screen capture, and the system, exposed over the Model Context Protocol.
Maintainers
Readme
macctl
Accessibility-first macOS desktop automation for Claude and other MCP clients.
macctl gives an MCP client first-class access to a Mac: it can see the screen, read the accessibility tree of any application to identify controls by role/title/identifier (not just pixels), drive the mouse and keyboard, manage windows, inspect processes and launchd services, work with files and the clipboard, and run shell commands — all gated behind explicit permission tiers, with a local audit log of every call. It is the macOS counterpart to winctl, Sitharaj's Windows equivalent.
Why macctl
- Accessibility-tree-first, not screenshot-and-click.
ax_snapshotandax_findlet a client locate "the Save button" or "the text field named Subject" by role/title/identifier viaAXUIElement, instead of guessing pixel coordinates from a screenshot and hoping the layout doesn't shift. - Correct global-point coordinates. Every coordinate macctl reports or
accepts is a global screen point in the CG coordinate space (top-left
origin of the main display), with each display's
backingScaleFactorreported alongside — no silent pixel/point confusion across Retina and external displays. - Reliable, paced input. Keystrokes are synthesized layout-independently; text longer than ~200 characters is delivered via a clipboard paste (with the prior clipboard contents restored afterward) instead of hundreds of individually racy synthetic keypresses.
- Stable, opaque handles.
windowHandleandelementHandlevalues are fingerprinted (pid + window/element identity) rather than raw pointers, so a handle from one call either still resolves correctly on the next call or fails cleanly — it never silently targets the wrong window. - Honest, verified failures. Window moves are read back and the verified
final frame is returned (apps clamp geometry); TCC failures come back as a
structured
PERMISSION_MISSINGerror naming the exact permission and the exact app to grant it to, not a generic timeout. - Tiered permissions + audit log. Every tool belongs to one of five permission tiers. Tools in a disabled tier are never registered with the MCP client — it cannot see or attempt them, not just be told no. Every call is appended to a local, redacted JSONL audit log.
Requirements
- macOS 14 (Sonoma) or later recommended; built and tested on macOS 26.
- Apple Silicon or Intel — macctl ships a universal (arm64 + x86_64) helper binary, no compilation required on your machine.
- Node.js 20+.
Grant permissions
macctl's native tools (window management, input, accessibility, screen capture) need macOS permissions granted through System Settings → Privacy & Security, not through macctl itself. This is the single most common source of confusion, so read this before anything else:
The permission grant attaches to whichever app launches macctl — Claude Desktop, Terminal, iTerm2, VS Code, or whatever process ultimately spawns
node dist/index.js— never tonodeormacctlthemselves. If you grant Accessibility to the wrong app, or grant it and then keep running the old process, tools will keep failing withPERMISSION_MISSING.
Steps:
Run the doctor tool to find out exactly which app to grant, and get a direct System Settings link for each permission:
npx @sitharaj88/macctl doctorThis prints the responsible app (identified by walking the process tree up to the nearest
.appbundle), the current status of Accessibility, Screen Recording, and Full Disk Access, and ax-apple.systempreferences:deep link for each one that isn't granted yet.Open System Settings → Privacy & Security and grant:
- Accessibility — required for window management, mouse/keyboard
input, and the
ax_*accessibility tools. - Screen Recording — required for
capture,capture_region,capture_window, andlist_capturable_windows. - Full Disk Access — only needed if you point
MACCTL_ALLOWED_PATHSoutside your user-visible home folders.
- Accessibility — required for window management, mouse/keyboard
input, and the
Restart the host app (Claude Desktop, your terminal, etc.) after granting. macOS does not apply a fresh TCC grant to an already-running process.
You can also trigger the native grant dialogs directly instead of only getting deep links:
npx @sitharaj88/macctl doctor --promptTools that hit a missing permission at runtime return a structured
PERMISSION_MISSING error with the same "which app, which permission, which
link" detail — call system_doctor again after granting to confirm.
Installation
Claude Desktop (recommended)
Download the latest macctl.mcpb from
GitHub Releases and
double-click it. The bundled helper binary is Developer ID signed and
notarized, so Gatekeeper accepts it without extra steps on a fresh download.
Claude Code
claude mcp add macctl -- npx -y @sitharaj88/macctlManual MCP client configuration
Add an entry to your client's MCP server config (Claude Desktop's
claude_desktop_config.json, or the equivalent for your client):
{
"mcpServers": {
"macctl": {
"command": "npx",
"args": ["-y", "@sitharaj88/macctl"]
}
}
}Then follow Grant permissions above and restart the client.
Permission tiers & profiles
Every tool belongs to exactly one tier. A tool in a disabled tier is never
registered with the MCP client — this is an allowlist enforced at
tools/list time, not a runtime check the model could talk its way around.
| Tier | Covers |
|---|---|
| observe | Read-only: screenshots, window/AX inspection, system/process info, file reads. |
| interact | Mouse/keyboard input, window focus/move/close, AX actions, clipboard. |
| filesystem | Writing, deleting, moving, and creating files/directories. |
| manage | Process start/kill, launchd service control, notifications. |
| shell | Arbitrary shell commands via shell_run — unsandboxed, runs with the host process's own OS permissions. |
Profiles bundle tiers together:
| Profile | Tiers enabled |
|---|---|
| readonly | observe |
| standard (default) | observe, interact, filesystem |
| full | observe, interact, filesystem, manage, shell |
system_doctor is always registered regardless of profile — it's how you
diagnose everything else.
Set the profile via MACCTL_PROFILE, or bypass profiles entirely with an
explicit tier list via MACCTL_TIERS (e.g. MACCTL_TIERS=observe,interact),
which overrides the profile's tier set completely rather than adding to it.
MACCTL_PROFILE=readonly npx @sitharaj88/macctl # observation only
MACCTL_PROFILE=full npx @sitharaj88/macctl # everything, incl. shell
MACCTL_TIERS=observe,interact npx @sitharaj88/macctl # explicit overrideConfiguration
All configuration is via environment variables, read once at startup:
| Variable | Default | Description |
|---|---|---|
| MACCTL_PROFILE | standard | readonly | standard | full. See profiles above. |
| MACCTL_TIERS | (unset) | Comma-separated tier list (observe,interact,filesystem,manage,shell) that, if set, replaces the profile's tier set entirely. |
| MACCTL_ALLOWED_PATHS | (unset → home dir, /tmp, /private/tmp, /Volumes) | Colon-separated (:) list of directories file/exec-path tools are confined to. |
| MACCTL_DENIED_PATHS | (unset) | Colon-separated (:) list of additional directories to deny, layered on top of the built-in denylist (~/Library/Keychains, ~/.ssh, ~/Library/Application Support/com.apple.TCC, ~/Library/Containers, ~/Library/Group Containers, ~/Library/Cookies, ~/Library/Safari, ~/Library/Mail, /private/var/db). Denied always wins over allowed. |
| MACCTL_CONFIRM_DESTRUCTIVE | true | If true, destructive tools require an explicit confirm: true argument. Set to 0/false/no/off to disable. |
| MACCTL_AUDIT_LOG | ~/Library/Logs/macctl/audit.jsonl | Path to the audit log file. |
| MACCTL_AUDIT_DISABLED | false | Disable audit logging entirely. |
| MACCTL_MAX_IMAGE_WIDTH | 1600 | Screenshots wider than this (in pixels) are downscaled by the helper before being returned. |
| MACCTL_COMMAND_TIMEOUT_MS | 60000 | Default timeout for shell_run, overridable per call. |
| MACCTL_HELPER_PATH | (unset → auto-resolved next to the installed package, native/bin/macctl-helper) | Explicit path to the native helper binary — mainly for local development (scripts/build-helper.sh --dev). |
Complete tool reference
41 tools total. Tier is shown per tool; destructive tools additionally
require confirm: true when MACCTL_CONFIRM_DESTRUCTIVE is enabled
(the default).
Doctor (always registered, any profile)
| Tool | Tier | Description |
|---|---|---|
| system_doctor | observe | TCC permission status, responsible app, native helper reachability, active profile/tiers, audit log location, version info. |
Screen (5)
| Tool | Tier | Description |
|---|---|---|
| list_monitors | observe | List connected displays with frame, visible frame, backing scale factor, and which is main. |
| capture | observe | Screenshot a full display (main by default). |
| capture_region | observe | Screenshot a rectangular region in global screen points. |
| capture_window | observe | Screenshot a single window by windowHandle. |
| list_capturable_windows | observe | List on-screen windows available for capture_window, via ScreenCaptureKit. |
Windows (7)
| Tool | Tier | Description |
|---|---|---|
| window_list | observe | List all on-screen windows with title, owning app, pid, bounds, layer, minimized state, and a windowHandle. |
| window_get_active | observe | Get the frontmost app and its focused window. |
| window_get_desktop_info | observe | Get screen layout, light/dark appearance, and cursor position. |
| window_focus | interact | Activate a window's owning app and raise the window. |
| window_set_state | interact | Minimize, restore, maximize, fullscreen, hide, or show a window. |
| window_move | interact | Move and/or resize a window; returns the verified final frame. |
| window_close | interact, destructive | Close a window by pressing its AXCloseButton. |
Input (7)
| Tool | Tier | Description |
|---|---|---|
| input_move_mouse | interact | Move the mouse cursor to a global screen point. |
| input_click | interact | Click at a global screen point (left/right/middle, single/double/triple). |
| input_drag | interact | Press, drag through interpolated points, and release. |
| input_scroll | interact | Post a scroll-wheel event, optionally moving the cursor first. |
| input_type | interact | Type Unicode text at the current keyboard focus (layout-independent; paste for long text). |
| input_press_keys | interact | Press a keyboard chord, e.g. "cmd+shift+4". |
| input_key_hold | interact | Hold a single key down or release it (auto-releases after 30s). |
Accessibility (4)
| Tool | Tier | Description |
|---|---|---|
| ax_snapshot | observe | Walk the AXUIElement tree of an app or window (role/title/value/description/identifier/enabled/frame per node). |
| ax_find | observe | Bounded search over an app's/window's tree by role, title/value substring, and/or identifier. |
| ax_invoke | interact | Perform an accessibility action on an element (default AXPress). |
| ax_set_value | interact | Set an element's value directly, or via focus + Cmd+A + type fallback, with verified read-back. |
System & Processes (8)
| Tool | Tier | Description |
|---|---|---|
| system_info | observe | CPU, memory, disk, network, battery, graphics, macOS version, hardware model. |
| list_services | observe | List launchd services: running (launchctl list) joined with installed LaunchAgents/LaunchDaemons. |
| list_installed_apps | observe | List installed applications: name, bundle id, version, path. |
| process_list | observe | List running processes (pid, ppid, cpu%, mem%, rss, elapsed, command). |
| control_service | manage, destructive | Restart/stop/start/enable/disable a launchd LaunchAgent. LaunchDaemons (system domain) refuse with PRIVILEGE_REQUIRED. |
| notify | manage | Show a macOS notification banner. |
| process_start | manage | Launch an app (open -a/open -b), open a document/URL, or spawn a bare executable. |
| process_kill | manage, destructive | Terminate a process by pid (SIGTERM, optionally escalating to SIGKILL). |
Files, Clipboard & Shell (9)
| Tool | Tier | Description |
|---|---|---|
| file_known_folders | observe | List well-known macOS folders (home, Desktop, Documents, Downloads, iCloud Drive, etc.) with existence/allow-list status. |
| file_list | observe | List a directory's entries, optionally recursive with a depth cap. |
| file_read | observe | Read a file as utf8 text or base64, capped at maxBytes. |
| file_search | observe | Search a directory tree by name glob and/or content regex, or accelerated via mdfind. |
| file_write | filesystem, destructive | Write, append to, or create a text/base64 file. |
| file_manage | filesystem, destructive | Copy, move, delete a file/directory, or mkdir -p. |
| clipboard_read | interact | Read clipboard text, file references, and image presence. |
| clipboard_write | interact | Replace clipboard contents with text or file references. |
| shell_run | shell | Run a command via /bin/zsh -c, capturing stdout/stderr/exit code. |
Example workflow
A realistic multi-tool sequence — opening TextEdit, finding its text area via the accessibility tree (not coordinates), typing, and verifying visually:
1. process_start { app: "TextEdit" }
2. window_get_active → windowHandle for the new TextEdit window
3. ax_find { windowHandle, role: "AXTextArea" }
→ elementHandle for the document's text area
4. ax_invoke { elementHandle } # focus/click it
5. input_type { text: "Meeting notes...\n\n- Discuss Q3 roadmap" }
6. ax_snapshot { windowHandle, maxDepth: 5 } # read the value back, verify it landed
7. capture_window { windowHandle } # visual confirmation
8. window_move { windowHandle, x: 100, y: 100 }
9. input_press_keys { keys: "cmd+s" } # save
10. window_close { windowHandle, confirm: true } # destructive — needs confirmEach step uses a real tool name and an opaque handle produced by an earlier step — never a guessed coordinate or a raw pointer.
Safety & audit
- Destructive-action confirmation.
window_close,file_write,file_manage,control_service, andprocess_killare marked destructive. WhenMACCTL_CONFIRM_DESTRUCTIVEis enabled (the default), each call must includeconfirm: trueor it's refused withCONFIRMATION_REQUIRED— no first-try accidental deletes. - Path containment. File and executable-path tools resolve the real
(symlink-followed) path and check it against
MACCTL_ALLOWED_PATHS/MACCTL_DENIED_PATHS, with a built-in denylist (Keychains,~/.ssh, TCC store, app containers, Safari/Mail data,/private/var/db) that always wins over anything allowed. - No silent privilege escalation. macctl never shells out to
sudo. Actions that would need elevated privileges (e.g. controlling a system-domainLaunchDaemon) fail honestly withPRIVILEGE_REQUIREDrather than prompting for or assuming root. - Audit log. Every tool call is appended as one JSON line to
~/Library/Logs/macctl/audit.jsonl(configurable, or disable withMACCTL_AUDIT_DISABLED=1): timestamp, tool name, outcome, duration, error code, and redacted arguments (long strings and base64-looking blobs are replaced with[redacted N chars]before being written).
Privacy
See PRIVACY.md for the full policy. In short: macctl runs entirely locally and has no telemetry. Screenshots, accessibility-tree contents, clipboard data, and file contents are returned only to the MCP client that requested them — nothing is sent anywhere by macctl itself.
Development
git clone https://github.com/sitharaj88/macctl.git
cd macctl
npm install
npm run build:all # tsc + universal Swift helper build (native/bin/macctl-helper)npm run build:helper (via scripts/build-helper.sh) builds the Swift
helper as a universal (arm64 + x86_64) binary using Swift Package Manager;
run it with --dev for a fast, current-arch-only build during local
iteration.
npm run smoke # spawns the server, exercises TCC-free tools across all 3 profiles
npm run smoke:full # also exercises TCC-dependent tools (skips gracefully if ungranted)
node scripts/verify-interactive.mjs # full TextEdit round-trip — needs a real GUI session + Accessibility/Screen Recording grantsverify-interactive.mjs drives an actual TextEdit window end-to-end (open →
find the text area → type → read the value back → screenshot → move →
close), so it must run in a real logged-in GUI session with permissions
already granted to your terminal — it will not work over SSH or in CI.
Publishing
- npm:
npm publish --access public - .mcpb bundle:
npx @anthropic-ai/mcpb pack(respects.mcpbignore; producesmacctl.mcpbfor GitHub Releases / Claude Desktop's drag-and-drop install). - MCP registry:
mcp-publisher publish(usingserver.json).
👤 Author
Sitharaj Seenivasan
- 🌐 Website: sitharaj.in
- 💼 LinkedIn: sitharaj08
- 💻 GitHub: sitharaj88
☕ Support
If this project helps you, consider buying me a coffee — it keeps the work going.
📄 License
Licensed under the Apache License 2.0. © 2026 Sitharaj Seenivasan.
