Skip to content
Conduit

mcp server

safari-mcp

Native MCP server to drive Safari on macOS — navigate, click, type, run JS, screenshots, computer-use, file upload, and console/network capture. Zero runtime deps.

erwinzhang7/safari-mcpcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

This project does not publish an install method we can turn into a config. Follow the README instead.

README

synced Oct 26, 2026

Safari MCP

A native Model Context Protocol server that lets an MCP client (e.g. Claude Code) drive Safari on macOS — navigate, read, click, type, run JS, take screenshots, do screenshot-driven mouse/keyboard control, upload files, and capture console/network traffic. The Safari equivalent of a "browser in your agent," built native with zero runtime dependencies.

Two complementary halves:

  • Native driver (driver/) — a single Swift binary that speaks MCP over stdio and drives Safari via AppleScript (do JavaScript), CoreGraphics (screencapture, CGEvent), and NSPasteboard. Hand-rolled JSON-RPC and WebSocket — no SDK, no ws, no Node.
  • Safari extension (extension/) — a vanilla-JS MV3 extension whose only job is to capture the current site's console + network and stream it to the driver over a loopback WebSocket. Its popup is a small status panel (bridge health, capture state, event counts, the auto/local switch, and per-site permission management). No API key, no embedded chat.
MCP client ──stdio(MCP)──▶ safari-mcp (Swift) ──AppleScript/CGEvent──▶ Safari ──▶ pages
                                  ▲
                                  │ ws://127.0.0.1:8787 (console/network telemetry)
                                  │
                       Safari extension (MV3) ──▶ status + permission popup

Why this shape

Safari's extension model is hostile to silent automation (per-site prompts, sandboxing, unsigned-extension resets). So the heavy lifting is native (AppleScript + CoreGraphics), which needs only one-time macOS permission grants — and the extension stays tiny, doing the one thing native automation can't easily do: capture console/network from inside the page. See RESEARCH.md for the verified platform constraints and PLAN.md for the design.

Requirements

  • macOS with Safari, and Xcode (for building/installing the extension).
  • A Swift toolchain (swift), Python 3, and Node (for the test runners only).
  • A code-signing identity. A stable identity (Apple Development or Developer ID) is strongly recommended so macOS permission grants persist across rebuilds.

Build & install

Quick start (build the driver and register it globally in one command):

# Optional but recommended: a stable signing identity so macOS permission grants persist.
export SMCP_SIGN_IDENTITY="Apple Development: you@example.com"
make install          # builds + signs the driver, registers it as a global MCP server
safari-mcp mode auto  # optional: one shared server for all sessions (flip back with mode local)
make build-extension  # generates the Xcode project; then build/run it once (step 2 below)

Then restart your MCP client and finish the Safari steps below. The two halves in detail:

Release builds

make release builds a universal binary, signs it with the Developer ID identity (Developer ID Application: Closiq Inc. (QFJW3NFT2M), hardened runtime, the Apple Events entitlement, a secure timestamp), notarizes it with notarytool using an App Store Connect API key, and checks the result with codesign --verify --strict and spctl -a -t install. Output: dist/safari-mcp and dist/safari-mcp-v<version>-macos.zip. make install-release DESTBIN=~/bin/safari-mcp installs that binary.

A bare command-line binary can't have a notarization ticket stapled to it (stapling works on apps, disk images and installer packages), so Gatekeeper looks the notarization up online the first time a downloaded copy runs. The release zip carries the binary, LICENSE and a short README.

1. Driver

# Signs with $SMCP_SIGN_IDENTITY (defaults to ad-hoc "-"). Set it to a stable identity so the
# macOS Automation grant sticks across rebuilds, e.g. SMCP_SIGN_IDENTITY="Apple Development: you@example.com".
./scripts/build-driver.sh release
# Register it (the script prints the exact binary path; `make install` does this for you):
claude mcp add safari --scope user -- "$(cd driver && swift build -c release --show-bin-path)/safari-mcp"

One-time macOS grants (per the signing identity):

  • Approve the Automation prompt for Safari the first time a tool runs (System Settings ▸ Privacy & Security ▸ Automation).
  • Safari ▸ Settings ▸ Advanced ▸ Show features for web developers, then Develop ▸ Allow JavaScript from Apple Events.
  • For safari_screenshot: Screen Recording permission (prompted on first use).
  • For safari_computer with real: true: Accessibility permission (prompted on first use).

2. Extension

./scripts/build-extension.sh             # converts extension/ → an Xcode project under app/
open app/SafariMCP/SafariMCP.xcodeproj

In Xcode: set your signing Team on both targets (a free Apple ID works), then Product ▸ Run to install. In Safari: Develop ▸ Allow Unsigned Extensions (resets each launch unless the extension is signed by a real Developer ID), Settings ▸ Extensions ▸ enable Safari MCP, then grant a site capture via the toolbar popup. extension/ is the source of truth; app/ is a regenerated build artifact (gitignored) — re-run build-extension.sh after editing extension/.

Driver changes require a fresh MCP-client session to load (the server set is fixed at session start). Extension changes hot-reload in Safari, but reinstalling the app can disable the extension — re-enable it in Settings ▸ Extensions if so.

Auto mode: one shared server for every session

By default each MCP client session starts its own safari-mcp process, and each one drives Safari itself. With several Claude sessions open that means several drivers, several Automation clients and one bridge election per session. Auto mode keeps a single long-lived server that owns Safari, the extension bridge and the allowlist, and turns each session's safari-mcp into a thin shim:

Claude session ──stdio──▶ safari-mcp (shim) ─┐
Claude session ──stdio──▶ safari-mcp (shim) ─┼─tcp 127.0.0.1:8790 + token─▶ safari-mcp --server ──▶ Safari
Claude session ──stdio──▶ safari-mcp (shim) ─┘     (LaunchAgent, Aqua session)        ▲
                                                            ws://127.0.0.1:8787 ──────┘ extension

Turning it on and off

Auto mode is a switch you can flip at any time. The MCP client always runs plain safari-mcp; the process reads the mode when it starts.

safari-mcp mode          # current mode, where it came from, LaunchAgent and server state
safari-mcp mode auto     # shared server: writes the mode file, installs + loads the LaunchAgent
safari-mcp mode local    # in-process (the default): writes the mode file, stops and removes the agent

The same switch is in the extension popup (Driver mode: Auto | Local). The popup asks whichever driver owns the bridge, which runs safari-mcp mode auto|local for it a moment after answering; switching to local from the popup stops the shared server that answered, which is why the switch runs after the reply. If SAFARI_MCP_MODE is set in that driver's environment the popup shows the mode read-only and says why. With no driver running it shows "Driver not running" and the commands above. Web pages cannot use the switch: the bridge refuses WebSocket handshakes from web origins, and the extension takes the request only from its own popup.

The mode file is ~/Library/Application Support/safari-mcp/mode (auto or local; missing means local). SAFARI_MCP_MODE=local|auto in a session's environment overrides it for that session, and a flag (--local, --auto) overrides both.

A flip applies to sessions started afterwards. Sessions already running keep the mode they started in, with one exception:

  • After mode auto, sessions that started local stay local until they restart.
  • After mode local, the server is booted out of launchd. It stops taking new calls, lets calls already running finish (up to 15 s), tells every shim bye and exits. Those shims find no LaunchAgent to start and fall back to running tools in-process for the rest of their session, keeping their bound tab. Nothing needs a restart.
  • After flipping back to mode auto, a shim that already fell back stays in-process; new sessions use the new server.

How it behaves

  • The shim answers initialize, ping and tools/list itself. Nothing connects until the first tool call, which connects to the server and, if it isn't running, starts it with launchctl kickstart.
  • Every session keeps its own tab binding and its own console/network buffer on the server, and its calls run in order. Different sessions run concurrently; their AppleScript calls are serialized on the server's main thread. The shim mirrors its bound tab, so after a server restart or a fallback it still targets the same tab.
  • If the server can't be reached (mode switched to local, refused, crashed and not back within 6 s), the shim falls back to running tools in-process, exactly like local mode, for the rest of that session. A call that was in flight when the server died returns an error saying it may or may not have taken effect; it is never silently retried. A server that is exiting answers bye instead of running the request, and the shim resends it to the new one.
  • The server exits by itself after 30 minutes with no sessions (SAFARI_MCP_IDLE_EXIT, seconds, 0 = never) and as soon as it is idle after its binary is replaced, so a make install takes effect on the next call. launchd restarts it only after a crash.
  • Loopback only. The shim must present the token in ~/Library/Application Support/safari-mcp/server.token, which the server creates (0600) on first start.

The server is its own process to macOS privacy controls, so it needs its own one-time grants: approve the Automation prompt for Safari on the first tool call, and grant Screen Recording (screenshots) and Accessibility (safari_computer with real: true) to safari-mcp when prompted. Use a stable signing identity so these stick. safari_bridge_status reports "mode": "server" when a call went through the shared server, "auto (in-process fallback)" when a shim fell back and "stdio" in local mode. Server log: ~/Library/Logs/safari-mcp-server.log.

Process modes: safari-mcp (mode from the mode file), --local (alias --stdio), --auto, --server. Settings: SAFARI_MCP_MODE, SAFARI_MCP_MODE_FILE, SAFARI_MCP_SERVER_PORT (8790), SAFARI_MCP_TOKEN_FILE, SAFARI_MCP_LAUNCHD_LABEL (dev.safari-mcp.server; empty disables auto-start), SAFARI_MCP_IDLE_EXIT, SAFARI_MCP_BRIDGE_PORT (8787). The SAFARI_MCP_* server settings set when you run mode auto are written into the LaunchAgent.

Process lifetime (all modes)

A stdio process exits when its client goes away: on stdin EOF (after answering what it already read, with a 5 s grace for a call still running), on a broken stdout, and when its parent process exits or it is reparented, even if stdin stays open. Nothing starts at launch: the extension bridge, the allowlist file and Safari are touched only by the first tool call.

Per-site permissions

Read-only tools (list tabs, read page text/DOM, screenshots, console/network) work anywhere. Anything that acts on a page or runs JS (click, type, set value, scroll, run_js, navigate, reload, computer-use input, storage writes, file upload) is gated per origin:

  • The first time the agent acts on an unauthorized site, the driver pops a native Allow / Deny dialog. Allow adds the origin to a persistent allowlist; the agent can never self-authorize.
  • Grant/revoke any time from the extension popup. Capture is folded into the same auth: a site must be allowed before console/network stream.

MCP tools

ToolWhat it does
safari_list_tabsList all tabs across windows (with stable tabIds)
safari_navigateOpen a URL in a tab (waits for load)
safari_new_tab / safari_close_tab / safari_activate_tabTab management (new tabs open in the background)
safari_reload / safari_back / safari_forwardHistory navigation
safari_resize_windowResize a window (responsive testing)
safari_get_page_textVisible page text (innerText)
safari_read_pageSnapshot interactive elements with stable refs
safari_findFind elements by CSS selector or visible text
safari_inspectComputed styles, bounding box, attributes, outerHTML
safari_click / safari_type / safari_set_valueInteract with elements by ref / selector / text
safari_scrollScroll by direction/amount, or a ref/selector into view
safari_wait_forWait for a selector / text / network-idle (with timeout)
safari_run_jsRun arbitrary JS, return its result (escape hatch)
safari_screenshotPNG of a Safari window — works on background/occluded windows
safari_computerScreenshot-driven mouse/keyboard control with an on-page cursor overlay
safari_uploadUpload a local file into a file input / drop zone (no native picker)
safari_clipboardRead/write the macOS clipboard (copy/paste workflows)
safari_storageRead/write localStorage, sessionStorage, cookies
safari_read_console / safari_read_networkCaptured console / network, filterable (needs the extension)
safari_bridge_statusIs the extension connected? how much is buffered?
safari_permissionsList or revoke per-site action permissions

Notes:

  • Most tools take an optional tabId (from safari_list_tabs); omit for the active tab. Tabs in non-front and occluded windows are addressed reliably and driven in the background.
  • safari_computer defaults to DOM-synthesized input dispatched inside the target tab — it works in the background, needs no Accessibility grant, and draws a cursor/click overlay. Set real: true for true OS-level CGEvent input (needs the window frontmost + Accessibility) for sites that reject synthetic events.
  • safari_read_network captures request/response headers and (for JSON/text responses) bodies up to 16KB; sensitive headers are redacted unless includeSecrets: true.

Tests

make test          # protocol, bridge, EPIPE, lifetime, auto-mode, toggle and mode-switch self-tests (no Safari)
make safari-test   # memory soak + auto-mode integration against real Safari (own windows only)
  • protocol_selftest.py — MCP protocol layer (initialize, tools/list, tools/call, errors).
  • bridge_selftest.mjs — the console/network bridge, with a Node WS client standing in for the extension; also checks the bridge stays down until the first tool call.
  • epipe_selftest.py — clean exit when the stdio pipe breaks.
  • lifetime_selftest.py — exit on stdin EOF and on parent death with stdin still open.
  • auto_selftest.mjs — auto mode end to end: lazy connect, shared server, per-session telemetry, token auth, concurrency, fallback, reconnect, binary-replaced handoff, idle exit.
  • toggle_selftest.mjs — safari-mcp mode auto|local both ways under a test-only launchd label: mode file and LaunchAgent, plain starts following the file, SAFARI_MCP_MODE overriding it, and a connected shim falling back in-process when mode local unloads the server.
  • mode_selftest.mjs — the popup's switch over the bridge (getMode / setMode): status fields, validation, reply-then-switch, one switch at a time, driver connections ignored, web-origin handshakes refused, SAFARI_MCP_MODE reported read-only, and setMode local answered by the shared server it then boots out (test-only launchd label).
  • memory_soak.py — footprint and descriptor count over a loop of real tool calls (--auto to go through the server); fails on growth.
  • auto_integration.py — tab binding across concurrent sessions, a server restart and a fallback.

FAQ

Safari's console shows errors from inject.js on sites I'm not debugging. Those are the page's own errors. inject.js wraps the page's console and fetch/XHR to capture them and then calls the originals, so Safari attributes the forwarded call to inject.js. The message text and the stack below it belong to the page; nothing is wrong with the extension.

"Invalid call to runtime.connect(). Tab not found." Older builds of the extension logged this in tabs Safari had not registered yet (prerendering pages, tabs being closed). The content script now connects only when it has something to send, never while prerendering or after pagehide, and backs off after a refusal. Rebuild and reload the extension if you still see it.

Repo layout

driver/        Swift package — the native MCP driver
extension/     MV3 Safari extension source (the single source of truth)
scripts/       build-driver.sh, release.sh, build-extension.sh, patch-xcodeproj.py, make-icons.py
app/           generated Xcode project (gitignored build artifact)
PLAN.md        architecture;  RESEARCH.md  verified platform findings

License

Apache License 2.0 — see LICENSE.

↑↓ navigate openesc close