Skip to main content
Glama
README.md
# macctl

[![npm version](https://img.shields.io/npm/v/%40sitharaj88%2Fmacctl.svg)](https://www.npmjs.com/package/@sitharaj88/macctl)
[![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
[![platform](https://img.shields.io/badge/platform-macOS-lightgrey.svg)](#requirements)

**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](https://github.com/sitharaj88/winctl), Sitharaj's Windows equivalent.

## Why macctl

- **Accessibility-tree-first, not screenshot-and-click.** `ax_snapshot` and
  `ax_find` let a client locate "the Save button" or "the text field named
  Subject" by role/title/identifier via `AXUIElement`, 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 `backingScaleFactor`
  reported 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.** `windowHandle` and `elementHandle` values 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_MISSING` error 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 to `node` or `macctl` themselves.** If you
> grant Accessibility to the wrong app, or grant it and then keep running the
> old process, tools will keep failing with `PERMISSION_MISSING`.

Steps:

1. Run the doctor tool to find out exactly which app to grant, and get a
   direct System Settings link for each permission:

   ```bash
   npx @sitharaj88/macctl doctor
   ```

   This prints the responsible app (identified by walking the process tree
   up to the nearest `.app` bundle), the current status of Accessibility,
   Screen Recording, and Full Disk Access, and a `x-apple.systempreferences:`
   deep link for each one that isn't granted yet.

2. 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`, and `list_capturable_windows`.
   - **Full Disk Access** — only needed if you point `MACCTL_ALLOWED_PATHS`
     outside your user-visible home folders.

3. **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:

```bash
npx @sitharaj88/macctl doctor --prompt
```

Tools 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](https://github.com/sitharaj88/macctl/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

```bash
claude mcp add macctl -- npx -y @sitharaj88/macctl
```

### Manual 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):

```json
{
  "mcpServers": {
    "macctl": {
      "command": "npx",
      "args": ["-y", "@sitharaj88/macctl"]
    }
  }
}
```

Then follow [Grant permissions](#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.

```bash
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 override
```

## Configuration

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 confirm
```

Each 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`, and `process_kill` are marked
  destructive. When `MACCTL_CONFIRM_DESTRUCTIVE` is enabled (the default),
  each call must include `confirm: true` or it's refused with
  `CONFIRMATION_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-domain `LaunchDaemon`) fail honestly with `PRIVILEGE_REQUIRED`
  rather 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 with
  `MACCTL_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](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

```bash
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.

```bash
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 grants
```

`verify-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`;
  produces `macctl.mcpb` for GitHub Releases / Claude Desktop's drag-and-drop
  install).
- **MCP registry:** `mcp-publisher publish` (using `server.json`).

---

## 👤 Author

**Sitharaj Seenivasan**

- 🌐 Website: [sitharaj.in](https://sitharaj.in)
- 💼 LinkedIn: [sitharaj08](https://www.linkedin.com/in/sitharaj08)
- 💻 GitHub: [sitharaj88](https://github.com/sitharaj88)

## ☕ Support

If this project helps you, consider buying me a coffee — it keeps the work going.

[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/sitharaj88)

## 📄 License

Licensed under the [Apache License 2.0](LICENSE). © 2026 Sitharaj Seenivasan.

TDQS

A4/5.0

Scored across 36 tools

Disambiguation5/5

Each tool targets a distinct resource and action—input, windows, capture, accessibility, files, clipboard, and system info are cleanly separated. Even within groups like capture (full/region/window) or file (list/read/search/write/manage), the intended operation is unambiguous.

Naming Consistency4/5

The majority follow a clear verb_noun pattern (input_*, window_*, file_*, clipboard_*, ax_*) or list_* for enumerations. Minor deviations like process_list instead of list_processes and capture_window/list_capturable_windows introduce slight inconsistency, but the overall convention is predictable.

Tool Count2/5

With 36 tools, this server exceeds the threshold where the count becomes heavy (25+). While the scope is broad (GUI automation, system info, files, clipboard), the large number is likely to overwhelm agents and suggests it could be split into smaller, more focused servers.

Completeness4/5

The surface covers a wide range of macOS automation: input, window management, screenshots, accessibility, system info, file operations, and clipboard. Minor gaps include no explicit application launch/termination, no arbitrary shell execution, and no menu bar interaction, but these are peripheral to the server's apparent purpose.

Maintenance

ActivitySlowing
ResponsivenessNo issues