safari-mcp
An MCP server that lets an agent use Safari on your Mac through AppleScript: open pages, read
them, click, fill forms, attach files, download files with the page's own sign-in, take
screenshots.
Why Safari rather than a headless browser: it is the browser you are already signed in to. The
agent can read a page behind your university proxy or download a report from an internal tool
without ever seeing a password. That is also why this server is built defensively.
What it will and will not do
- Its own window. The server opens a dedicated Safari window and works only there. Tabs you
already had open are invisible to it — it cannot list, read, switch or close them — unless you
start it with
SAFARI_MCP_SCOPE=all.
- Domain policy.
SAFARI_MCP_ALLOW_DOMAINS restricts it to named sites;
SAFARI_MCP_BLOCK_DOMAINS excludes sites and wins over the allow list. The tab's current
address is re-checked before every tool call, because pages redirect.
- Secrets. It never reads cookies, browser storage or saved passwords. Password, card and
one-time-code fields are redacted in every output, and typing into them is refused unless the
call carries
confirm_sensitive: true.
- No arbitrary script by default.
evaluate_js is off unless you set SAFARI_MCP_ALLOW_EVAL=1.
- Files stay in one directory. Uploads are read from, and downloads and screenshots written
to,
SAFARI_MCP_FILE_ROOT only. Nothing is overwritten.
- Untrusted content. Tool results carry a notice that page text is data, not instructions.
This reduces the risk of prompt injection; it does not remove it. Use the allow list for
anything unattended.
It cannot stop an agent from doing something unwise that you allowed. submit_form and click
act with your identity on the sites you let it visit.
Requirements
- macOS with Safari, Node.js 20 or newer.
- Safari → Settings → Advanced → "Show features for web developers", then
Settings → Developer → Allow JavaScript from Apple Events.
- On first use macOS asks whether the app running the server (your terminal, Claude Code, …) may
control Safari: allow it (System Settings → Privacy & Security → Automation).
- For
screenshot only: Screen Recording permission for that same app.
Install
skills download @spboucher/safari-mcp --extract ./safari-mcp
cd safari-mcp
npm ci
npm test
claude mcp add --transport stdio \
--env SAFARI_MCP_ALLOW_DOMAINS=example.org,intranet.example.com \
--env SAFARI_MCP_FILE_ROOT=$HOME/Downloads/agent \
safari -- node /abs/path/safari-mcp/server.mjs
[mcp_servers.safari]
command = "node"
args = ["/abs/path/safari-mcp/server.mjs"]
[mcp_servers.safari.env]
SAFARI_MCP_ALLOW_DOMAINS = "example.org"
{ "mcpServers": { "safari": { "type": "stdio", "command": "node", "args": ["/abs/path/safari-mcp/server.mjs"], "env": { "SAFARI_MCP_ALLOW_DOMAINS": "example.org" } } } }
| Variable | Effect | Default |
|---|
SAFARI_MCP_ALLOW_DOMAINS | Only these sites (and subdomains) | any http(s) site |
SAFARI_MCP_BLOCK_DOMAINS | Never these sites | none |
SAFARI_MCP_FILE_ROOT | Directory for uploads, downloads, screenshots | working directory |
SAFARI_MCP_SCOPE=all | Also see and act on tabs you opened | own window only |
SAFARI_MCP_ALLOW_EVAL=1 | Enable evaluate_js | off |
SAFARI_MCP_ALLOW_FILE_URLS=1 | Allow file:// pages | off |
SAFARI_MCP_DIALOGS=dismiss | Answer page confirm() with "cancel" | accept |
Tools
| Group | Tools |
|---|
| Session | safari_status, open_url, navigate (goto / back / forward / reload), list_tabs, select_tab, close_tab, resize_window |
| Read | page_info, snapshot, read_text, read_html, query_elements, list_links, extract_tables, list_forms |
| Act | click, hover, fill, fill_form, select_option, set_checked, press_key, submit_form, scroll, wait_for |
| Files and data | upload_file, download, fetch_text, screenshot |
| Opt-in | evaluate_js |
The usual loop is open_url → snapshot → act on a ref → wait_for / snapshot. Elements
can also be addressed by visible text, field label or CSS selector; when several match, the error
lists the candidates. Searches go through open shadow roots and same-origin iframes.
How it stays reliable
- Page readiness. Safari reports a tab's destination address as soon as a navigation starts,
while the old document still answers scripts. The server waits until the document's own address
has caught up and it is fully loaded, so it never acts on the page being left.
- Framework inputs. Fields are set through the native value setter with
input and change
events, which React- and Vue-style pages observe.
- No injection. Scripts are fixed; every value travels as an argument to
osascript, never
pasted into AppleScript or JavaScript source. No shell is ever invoked.
- Dialogs.
alert, confirm and prompt are intercepted and reported, so a page cannot
freeze the session.
- Downloads and uploads move bytes through the page in chunks: fetch with the page's cookies
for downloads, a constructed
File for uploads — no system file dialog.
Verified
test/safari.test.mjs drives the real Safari through the MCP protocol on a local fixture site:
navigation, redirects, history, tabs, late content, shadow DOM, iframes, ambiguous targets,
dialogs, a framework-style form, the sensitive-field guard and redaction, file upload (bytes
checked by hash), a cookie-protected download (checked by hash), same-origin enforcement,
screenshots, the domain policy applied to a page that moves, stale refs — and finally that the
user's own windows are exactly as they were. test/realweb.test.mjs repeats a read-only flow on
example.com and Wikipedia.
Limits
- Tested on Safari 27.0 / macOS 27.0 only.
- Cross-origin iframes cannot be read or driven: open their URL in a tab.
- Events are synthetic: pages react, the browser does not (no shortcuts, no autofill), and some
sites reject them.
- A dialog raised before the server first touches a page still blocks until dismissed by hand.
- Private windows, Reader, PDFs displayed in a tab and browser settings are not reachable.
- Uploads up to 20 MB, downloads up to 50 MB.
- The server takes no part in solving CAPTCHAs or bypassing bot checks.
- While it runs, a Safari window is visible on your screen. It is not brought to the front unless
select_tab is called with bring_to_front.