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.
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, 2026Safari 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), andNSPasteboard. Hand-rolled JSON-RPC and WebSocket — no SDK, nows, 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_computerwithreal: 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 shimbyeand 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,pingandtools/listitself. Nothing connects until the first tool call, which connects to the server and, if it isn't running, starts it withlaunchctl 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
byeinstead 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 amake installtakes 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
| Tool | What it does |
|---|---|
safari_list_tabs | List all tabs across windows (with stable tabIds) |
safari_navigate | Open a URL in a tab (waits for load) |
safari_new_tab / safari_close_tab / safari_activate_tab | Tab management (new tabs open in the background) |
safari_reload / safari_back / safari_forward | History navigation |
safari_resize_window | Resize a window (responsive testing) |
safari_get_page_text | Visible page text (innerText) |
safari_read_page | Snapshot interactive elements with stable refs |
safari_find | Find elements by CSS selector or visible text |
safari_inspect | Computed styles, bounding box, attributes, outerHTML |
safari_click / safari_type / safari_set_value | Interact with elements by ref / selector / text |
safari_scroll | Scroll by direction/amount, or a ref/selector into view |
safari_wait_for | Wait for a selector / text / network-idle (with timeout) |
safari_run_js | Run arbitrary JS, return its result (escape hatch) |
safari_screenshot | PNG of a Safari window — works on background/occluded windows |
safari_computer | Screenshot-driven mouse/keyboard control with an on-page cursor overlay |
safari_upload | Upload a local file into a file input / drop zone (no native picker) |
safari_clipboard | Read/write the macOS clipboard (copy/paste workflows) |
safari_storage | Read/write localStorage, sessionStorage, cookies |
safari_read_console / safari_read_network | Captured console / network, filterable (needs the extension) |
safari_bridge_status | Is the extension connected? how much is buffered? |
safari_permissions | List or revoke per-site action permissions |
Notes:
- Most tools take an optional
tabId(fromsafari_list_tabs); omit for the active tab. Tabs in non-front and occluded windows are addressed reliably and driven in the background. safari_computerdefaults to DOM-synthesized input dispatched inside the target tab — it works in the background, needs no Accessibility grant, and draws a cursor/click overlay. Setreal: truefor true OS-level CGEvent input (needs the window frontmost + Accessibility) for sites that reject synthetic events.safari_read_networkcaptures request/response headers and (for JSON/text responses) bodies up to 16KB; sensitive headers are redacted unlessincludeSecrets: 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|localboth ways under a test-only launchd label: mode file and LaunchAgent, plain starts following the file,SAFARI_MCP_MODEoverriding it, and a connected shim falling back in-process whenmode localunloads 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_MODEreported read-only, andsetMode localanswered 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 (--autoto 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.