# arc-control-mcp [![CI](https://github.com/DB-25/arc-control-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/DB-25/arc-control-mcp/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/arc-control-mcp.svg)](https://www.npmjs.com/package/arc-control-mcp) [![npm downloads](https://img.shields.io/npm/dm/arc-control-mcp.svg)](https://www.npmjs.com/package/arc-control-mcp) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](package.json) An MCP server that drives the Arc browser on macOS: tabs, navigation, page reading, DOM interaction and scripting. It exists because the bundled "Control Chrome" MCP server cannot be pointed at Arc. Arc's scripting dictionary looks like Chrome's, and differs in exactly the places that matter. **Who it is for:** anyone running an agent (Claude Code or another MCP client) on a Mac who wants it to work in Arc, the browser they are already signed in to, instead of a fresh automation profile. It reads pages, fills forms, clicks things, runs JavaScript, and keeps its own tabs separate from yours. **What it is not:** a cross-platform or cross-browser tool. It drives one browser on one operating system, through Apple Events, plus an optional [DevTools engine](#cdp-engine) for screenshots, trusted input and console/network capture. There is no headless mode. There is also no Docker image, and there cannot be one: Apple Events do not cross a container boundary, so a container has no way to reach the Arc running on your Mac. This is a 0.4.0 personal project, and the [known limitations](#known-arc-limitations) below are real. ## Requirements - macOS - [Arc](https://arc.net/) installed - Node 20 or newer (the [CDP tools](#cdp-engine) need Node 22 or newer, for its built-in `WebSocket`; they fail with a clear message on Node 20 and everything else keeps working) Two runtime dependencies, `@modelcontextprotocol/sdk` and `zod`. No build step. ## Install Nothing to clone. Any MCP client can start the server with `npx`, and `@latest` is also how it upgrades: the next start picks up a new release. ```json { "mcpServers": { "arc": { "command": "npx", "args": ["-y", "arc-control-mcp@latest"] } } } ``` > [!IMPORTANT] > That config is not sufficient on its own. Two macOS permissions still have to > be granted, one of them in Arc's own settings where nothing will prompt you > for it. Until both are granted, the server starts normally and then every > tool fails. This is by far the most likely reason a fresh install looks > broken: read > [the two macOS permissions](#the-two-macos-permissions), the next section. `package.json` declares `"os": ["darwin"]`, so on Linux or Windows the install stops with `EBADPLATFORM` instead of succeeding and then failing at the first Apple Event. Environment variables go in an `env` object alongside `args`; see [environment variables](#environment-variables).
Claude Code ```bash claude mcp add arc --scope user -- npx -y arc-control-mcp@latest ``` The `--` is required. Without it, `claude mcp add` reads the `-y` as one of its own flags and registers the wrong command. Then check what was registered: ```bash claude mcp get arc ```
Claude Desktop Edit `~/Library/Application Support/Claude/claude_desktop_config.json` and add the `mcpServers` block above, merging it with any servers already listed. Quit and reopen Claude Desktop: the file is only read at launch.
Cursor Add the same `mcpServers` block to `~/.cursor/mcp.json` for every project, or to `.cursor/mcp.json` for one project.
VS Code VS Code uses `servers`, not `mcpServers`, in `.vscode/mcp.json` for a workspace or in the file opened by the **MCP: Open User Configuration** command: ```json { "servers": { "arc": { "type": "stdio", "command": "npx", "args": ["-y", "arc-control-mcp@latest"] } } } ``` Or from the command line: ```bash code --add-mcp '{"name":"arc","command":"npx","args":["-y","arc-control-mcp@latest"]}' ```
From a git checkout, for development ```bash git clone https://github.com/DB-25/arc-control-mcp.git cd arc-control-mcp npm install claude mcp add arc-dev --scope user -- node "$PWD/src/index.js" ``` For any other client, the same thing as JSON. The path has to be absolute: the client's working directory is not yours. ```json { "mcpServers": { "arc-dev": { "command": "node", "args": ["/absolute/path/to/arc-control-mcp/src/index.js"] } } } ``` Register it under a different name than the published one, so you can tell which copy answered. See [CONTRIBUTING.md](CONTRIBUTING.md).
Check the install without an MCP client. Neither call touches Arc, so both work before the permissions below are granted: ```bash npx -y arc-control-mcp@latest --version npx -y arc-control-mcp@latest --help # tool count and environment variables ``` ## The two macOS permissions Both are asked for once, and both fail in a way that is confusing if you do not know to look here. 1. **Automation.** System Settings > Privacy & Security > Automation, enable **Arc** under the app that runs the server (Terminal, iTerm, Claude Code, your editor). Without it, *nothing* works: every tool fails on the first Apple Event. 2. **Allow JavaScript from Apple Events.** Arc > Settings > Advanced. Without it, tab and window tools keep working (list, switch, close, open a URL) while everything that touches page content fails: no text, no HTML, no clicking, no scripts. Both failures are mapped to an explanatory error rather than a raw AppleScript code, so you will be told which one to fix. A third, **optional** one is Accessibility (System Settings > Privacy & Security > Accessibility, for the same app, and Automation for System Events). It is what lets the server place the agent window on a second display and put your window back in front if Arc raised another. Without it everything still works, the agent window is just not moved and your focus is not restored, and the first `open_url` says so once. The user-activity gate below needs no permission. ## Why not just reuse the Chrome server Arc's scripting dictionary looks like Chrome's but differs in ways that break the Chrome server outright: | | Chrome | Arc | |---|---|---| | Tab id | integer | UUID string | | Switch tab | `set active tab index of window` | `select` command on the tab | | Back / forward | works on window or tab | tab only | | New tab | `open location` | `make new tab` on a window or space | | Grouping | none | spaces, plus a pinned / unpinned / topApp location | The Chrome server calls `parseInt(tab_id)` on every id, so against Arc every tool taking a tab id fails before reaching AppleScript. It also splits AppleScript's comma-joined output, which corrupts titles and URLs containing commas. ## Design Not restrictive by design. Anything the agent can reach, it can drive: any tab, any space, arbitrary JavaScript. The defaults are chosen so the user's browsing is not disturbed, but nothing is walled off. - **Implicit target**: a call with no `tab_id` uses a tab this agent opened. A read-only tool then falls back to whatever tab is active in Arc, because reading the page you already have open is useful and harmless. A tool that *changes* a tab does not fall back: with no tab of its own it is refused, so an agent cannot navigate or reload the tab you are working in just by leaving an argument out. The CDP reads `screenshot`, `console_messages` and `network_requests` follow the changing-tool rule too, since they attach a debugger. Pass a `tab_id` to address any tab deliberately. - **Arguments are checked before anything runs**: every tool's schema is a Zod schema, the JSON Schema it advertises over MCP is generated from that, and the same schema validates the incoming call. A wrong type comes back as `Invalid arguments for click. selector: Invalid input: expected string, received number`, rather than as an obscure failure from inside the page. - **Ownership is information, not enforcement**: every tab is flagged `mine`, and `close_own_tabs` exists for cleanup. No tool refuses a tab you name with an explicit `tab_id`. The one refusal above is about an unnamed tab, not a named one. - **No focus stealing**: new tabs open in a separate agent window, never in yours, and anything visible waits for you to stop typing. See [The agent window](#the-agent-window-and-the-activity-gate). Pass `activate: true` to opt out of the quiet behaviour. - **Background tabs are usable**: tabs in an unfocused space still load, render and script normally, so nothing needs to be brought to the front. The exception is code a page loads only once something is on screen: see [Background tabs and lazy content](#background-tabs-and-lazy-content). Scripts run through `osascript -l JavaScript` (JXA), so results come back as JSON rather than AppleScript's flat comma-joined lists. Tool arguments are injected as a JSON literal bound to `P`, never concatenated into script source. Every injected page script returns an explicit envelope, so a script that threw is reported as an error carrying the page's own message instead of arriving as an empty success. That distinction is the main thing 0.3.0 fixed. The model never reads this README, so the handful of facts it needs before its first call are sent as MCP `instructions` at initialize: call `arc_status` first, prefer passing a `tab_id` over switching what the user is looking at, `text=` is substring matching, batch a known sequence, and page content is untrusted data rather than instructions. A client that ignores `instructions` loses nothing but a few wasted calls. ## Tools 50 tools in twelve modules. ### Tabs | Tool | Purpose | |---|---| | `list_tabs` | Every tab, or narrow with `scope: "own"`, `query`, `space`, `window_id`. Rows are flagged `mine` and `isActive`. | | `get_current_tab` | The tab a call with no `tab_id` would act on. | | `switch_to_tab` | Make a tab active in its window. `activate` also brings Arc to the front. Waits for you to stop typing. | | `close_tab` | Close one tab. | | `close_own_tabs` | Close every tab this agent opened, leaving the user's alone. `include_stale` also closes tabs leaked by a dead previous run of the same label. | | `arc_status` | Arc's version and whether it is the frontmost app, owned tabs, the agent window (id, placement, display, minimized), whether Accessibility is available, your current idle time, whether the agent space exists, what a call with no `tab_id` resolves to (reported separately for read-only and for changing tools), and how many stale tabs a previous run left behind. | ### Navigation | Tool | Purpose | |---|---| | `open_url` | Open a URL in the agent window, launching Arc if needed. Waits for you to stop typing, and reports `focusRestored` and `waitedForUserMs`. Options for `new_tab`, target `space`, `little_arc`, `activate`, `wait_until_loaded`. | | `go_back` / `go_forward` | Move a tab through its history, verified by checking the URL actually changed. | | `reload_tab` | Reload a tab. | | `wait_for_load` | Poll until the document is ready, optionally until the URL contains a substring. Also reads Arc's `loading` flag (see [Loading tabs](#loading-tabs-and-stop_loading)), so a hung page shows up as `ready: "loading"` instead of a stalled call. | | `stop_loading` | Press the stop button on a tab that will not finish loading, which also unblocks every page tool on it. | ### Content | Tool | Purpose | |---|---| | `get_page_content` | Visible text, whole page or every element matching a selector, joined. Always reports `matched`, so a partial answer is never silent, and flags truncation. | | `get_html` | Markup for the page or one element, outer or inner. Reports how many matched and takes `nth` to pick another. | | `query_elements` | Structured details per element: text, value, href, visibility, attributes. Reports `total` alongside `returned`. The main way to see what is on a page before acting. | | `get_links` | Links with text and resolved href, filterable by substring. | | `get_page_info` | Title, URL, ready state, meta description, a headings outline, and counts of links, forms, inputs, buttons and iframes. A cheap first look at an unfamiliar page. | ### Snapshots | Tool | Purpose | |---|---| | `snapshot` | The page as a compact tree of roles, accessible names and `[ref=e12]` refs. Options: `interactive_only`, `scope`, `depth`, `max_chars` (a cut is always reported), `boxes`, `diff`. See [Snapshots and refs](#snapshots-and-refs). | ### Interaction | Tool | Purpose | |---|---| | `click` | Scroll into view and dispatch a real pointer sequence, so framework handlers fire. `nth` picks among matches. Waits for the DOM to settle and reports `settledMs`. | | `fill` | Set an input, textarea or contenteditable through the native setter, firing `input` and `change`. `submit: true` presses Enter afterwards. Also settles. | | `select_option` | Choose an option by value or visible label. Also settles. | | `press_key` | Dispatch a key press to an element or the focused element. Also settles. | | `scroll` | Scroll the page by direction and amount, or scroll one element into view. | | `wait_for_selector` | Poll until an element is `present`, `visible` or `absent`. | | `wait_for_text` | Poll until any of several strings, or a regex, appears in or disappears from the page's visible text or a scope selector. | | `fill_form` | Fill up to 50 fields in one call, each through the same checks as `fill`. Reports every field, is `ok: false` when any failed, and names which. Values are not echoed. | | `hover` | Send `pointerover`, `pointerenter`, `mouseover`, `mouseenter`, `pointermove` and `mousemove` at an element's center. Reports `coveredBy` like `click`. CSS `:hover` styles do not change: that is browser state a script cannot set. | | `type` | Type one character at a time (`keydown`, `keypress`, `beforeinput`, a native-setter append, `input`, `keyup`), for widgets that react to per-character events. Verifies the field afterwards: `ok: false` when characters did not go in, and the final value is reported (only its length for a password). `delay_ms` spaces the characters out. | ### Observation | Tool | Purpose | |---|---| | `capture_start` | Start recording console output, uncaught errors, unhandled rejections, and `fetch` and `XMLHttpRequest` calls (method, url, status, duration; never bodies or headers). Fails with `ok: false` when the page's CSP blocks it. See [Console and network capture](#console-and-network-capture). | | `capture_read` | Return what was recorded, oldest first, optionally clearing what it returned. Fails when no recorder is running rather than returning an empty list. | | `network_entries` | The page's requests from the browser's own resource timing. No recorder, so it works under any CSP, with the limits listed below. | ### Spaces | Tool | Purpose | |---|---| | `list_spaces` | Spaces in the front window with tab counts, which is active, and `topAppCount` for the sidebar favourites that belong to no space. | | `focus_space` | Switch the front window to a space. Rarely needed: unfocused tabs are fully scriptable. Waits for you to stop typing. | ### Scripting | Tool | Purpose | |---|---| | `execute_javascript` | Run JavaScript in a tab and return the result. Takes a bare expression or a statement body, validated before injection. | | `batch` | Run several tools in order in one call. `continue_on_error` keeps going past a failure. | Every tool states its full set of MCP annotations rather than leaving any to a client's inference, because the spec's defaults are counterintuitive: `destructiveHint` and `openWorldHint` both default to true. Read tools are annotated read-only. Four tools are annotated destructive: `close_tab` and `close_own_tabs`, plus `execute_javascript` and `batch`, which can do anything a page can do. `openWorldHint` is true for everything that touches page content, and false only for the tools that read or move Arc's own tab and space bookkeeping. ### Local data Read Arc's own files instead of driving Arc: see [Reading Arc's local data](#reading-arcs-local-data). All four are read-only, never touch a page, and answer in milliseconds. | Tool | Purpose | |---|---| | `sidebar_tree` | Every space with pinned items (folders nested) and unpinned tabs, plus top apps. Each tab has `id` (the `tab_id` `list_tabs` reports), `title`, `url`, `lastActiveAt`. `space`, `max_items`, `include_urls`, and `match_live` to mark each tab open or not. | | `find_stale_tabs` | Tabs idle for `days` (default 7), oldest first, with space and `pinned` / `unpinned` / `topApp`. Also reports exact duplicate URLs across the sidebar. | | `search_archive` | Search archived and closed tabs by text, newest first. `reason` narrows to `auto` or `manual`, `since_days` to a window. | | `search_history` | Search browsing history by text and time window. **Off unless `ARC_MCP_ALLOW_HISTORY=1`.** | ### CDP engine > **Security warning.** This engine talks to Arc over the Chrome DevTools > Protocol, and Arc only serves that when **you** launch it with > `--remote-debugging-port`. The port is **unauthenticated** and bound to > loopback: any process running as you on this Mac can then drive Arc through > it, read every page, and use your signed-in sessions, with no prompt. Arc never > does this by default, nothing here turns it on for you, and a Sparkle update > relaunches Arc without the flag. Turn the engine off with `ARC_MCP_CDP=0`, and > close the port by relaunching Arc normally. Read > [scripts/arc-cdp-setup.md](scripts/arc-cdp-setup.md) before enabling it. Everything above works through Apple Events and injected JavaScript, which has three hard limits: every event is synthetic (`isTrusted` false), there are no screenshots, and a page's console and network are invisible. The CDP engine removes them. It is **on by default in the server but inert until Arc exposes the port**: the first CDP tool probes `127.0.0.1:9222` (a failed probe is remembered for 10 seconds, so it is never paid for per call). If nothing answers, every CDP tool returns `ok: false` with the setup steps, and every other tool behaves exactly as before. Setup is three commands (details, the optional launchd healer and the security trade-off are in [scripts/arc-cdp-setup.md](scripts/arc-cdp-setup.md)): ```bash # 1. Quit Arc (Cmd-Q). It restores your tabs. open -a Arc --args --remote-debugging-port=9222 # 2. relaunch with the flag curl -s 127.0.0.1:9222/json/version # 3. verify arc-control-mcp --check-cdp # or probe with the CLI, changing nothing ``` How it fits together: - **Tabs still come from Apple Events.** CDP only attaches to existing pages; it never creates a target (`Target.createTarget` crashes Arc). - **A tab is mapped by a nonce, not by URL or title.** The Apple Event side writes a one-time random value into the page's DOM (`data-arc-mcp-tab`), and the CDP side looks for it. A target is used only after the nonce is found in it, so a different Chromium listening on 9222 can never be driven by mistake. Only targets at the tab's own address (fragment and trailing slash ignored) are probed, once more after a short wait in case a redirect was still settling, and a target on an origin your [guardrails](#guardrails) forbid is never probed at all. The mapping is cached per tab and revalidated on every use, since a navigation drops the attribute. - **Refs and semantic selectors work here too.** `ref=e12`, `role=`, `label=` and `placeholder=` are resolved first on the Apple Event side, where the ref table lives (CDP cannot see Arc's isolated world), which stamps the element with a one-time `data-arc-mcp-target` attribute. The DevTools call then acts on that element and the attribute is removed afterwards. A stale ref fails exactly as in `click`, and `reResolved` is reported. - **Tab rules are unchanged.** `tab_id` works as elsewhere, and the tools that change a tab never fall back to the tab you are looking at. `screenshot`, `console_messages` and `network_requests` read, but they attach a debugger, so with no `tab_id` they follow the same rule: a tab the agent opened, or a refusal, never the tab you are on. - **Capture starts at first attach.** `console_messages` and `network_requests` only see what happened after a CDP tool first touched the tab. CDP has no history to read back, so call one before the action you want to observe. - **Nothing sensitive is kept.** No request or response bodies, and headers are opt-in with `cookie`, `authorization`, `set-cookie` and any header whose name has `token`, `secret`, `key`, `session` or `signature` in it, or starts with `x-auth`, redacted as they arrive. URLs are shown without their query string and fragment unless `include_query` is set, since queries often carry tokens. | Tool | Purpose | |---|---| | `cdp_status` | Whether the engine can be used: port, browser, page target count, the security warning. `ok: false` with setup steps when nothing answers. | | `screenshot` | PNG or JPEG (`quality`) as MCP image content: the viewport, `full_page` (capped at 16384 px), or one element by `selector`. Works on a background tab without bringing it forward; `activate: true` is the only way it brings one forward, and then it waits for you to pause like `open_url` does and puts your window and application back afterwards (`focusRestored`). | | `trusted_click` | Real mouse click (`isTrusted` true) at the element center after scrolling it into view. `button`, `click_count` (2 gives a `dblclick`). Refuses a disabled control; reports `coveredBy`. | | `trusted_type` | Real typing. One `insertText` by default, or `per_key: true` for keydown, keypress, input and keyup per character (search-as-you-type). `clear` replaces the contents. Fails with `ok: false`, typing nothing, when the field does not take focus. | | `trusted_press_key` | Real key events with modifiers: `Enter` submits a form, `Tab` moves focus, `Meta+A` selects all. Reports the focused element afterwards. | | `trusted_hover` | Real pointer move; reports whether `:hover` applied. | | `drag` | Press, move in steps, release, between selectors or coordinates. Handles native HTML5 drag and drop. | | `upload_file` | Set a file input from absolute paths. Refuses relative, missing, non-regular, credential, shell-history, mail and browser-profile paths (`~/.ssh`, `~/.aws`, Arc and Chrome profiles, Messages, Mail, Safari, Keychains, `.env` and `.env.*`, `.zsh_history`, `.bash_history`, and similar), whatever the letter case of the path. | | `handle_dialog` | Accept or dismiss an `alert`, `confirm`, `prompt` or `beforeunload`. Other tools report an open dialog instead of hanging. | | `console_messages` | Buffered `console.*`, uncaught exceptions and browser log entries, filterable by `level`. | | `network_requests` | Method, url (no query string unless `include_query`), status, type, timing and size of each request. `include_headers` is opt-in and redacted. No bodies. | Limits worth knowing: element selectors reach the top-level document only, not iframes; a `Meta+` shortcut is sent with its editing command because macOS handles those in the menu bar, not the page; and a JavaScript dialog blocks all page access until `handle_dialog` clears it. ### Selectors Every selector argument accepts either: - a **CSS selector**, passed straight to `querySelectorAll`, or - **`text=Some label`**, which matches on visible text. This is **substring** matching, and it is case-insensitive. Exact matches are ranked first, so `text=Save` prefers a button labelled exactly "Save" over one labelled "Save and close". Innermost matches win over their ancestors. Tools that act on a single element report how many matched, so a vague label is visible rather than silent; pass `exact: true` to require the whole text, or `nth` to pick a different match. - **`ref=e12`**, a ref from `snapshot`. - **`role=button[name="Save"]`**, an ARIA or implicit role with an exact accessible name, or `role=button[name~="sav"]` for a case-insensitive substring. Elements hidden from the accessibility tree are not matched. - **`label=Email`** and **`placeholder=Search`**, the control a label names or the field carrying that placeholder. Substring, exact matches first, like `text=`. `execute_javascript` takes either a bare expression (`document.title`) or a statement body (`const rows = [...]; return rows.length`). Which one it used is reported as `form`, either `"expression"` or `"statement"`. A statement body yields a value only through `return`: `let n = 2; n * 3` comes back as `null` with a note telling you to add one, because producing `6` there would require `eval` inside the page and that breaks on any site with a strict Content-Security-Policy. Broken syntax is rejected in Node with the real parser message, before Arc is contacted at all. The `form` field matters because it is what makes a legitimate `null` distinguishable from a script that failed, which used to be impossible. If a value has no useful JSON representation, for example a DOM node or `window`, the response carries a `note` explaining that rather than a bare `{}`. ### Snapshots and refs `snapshot` reads a page the way a screen reader would, and gives every element a ref, so a model can act on what it read instead of guessing selectors: ``` - heading "Sign in" [level=1] [ref=e3] - textbox "Email" [ref=e4] value="a@b.co" (required) - button "Sign in" [ref=e5] (disabled) ``` Then `click` with `selector: "ref=e5"`. Every tool that takes a selector accepts a ref, and also `role=`, `label=` and `placeholder=` forms. - Roles come from ARIA or the element's implicit role. Names come from `aria-labelledby`, `aria-label`, a `