safari-mcp
Use MCP to automate your real, logged-in Safari: read any page, inspect elements, and perform gated write actions like clicking, filling, navigating, opening tabs, or running JS.
List all open Safari tabs and get handles (window/index or URL/title substring).
Read rendered visible text from a whole tab or a CSS-selected element.
Find and inspect elements matching a CSS selector before interacting.
Open new Safari tabs and wait for load, returning the final URL after redirects.
Navigate existing tabs to URLs, with Safari-problem characters percent-encoded.
Click elements matched by CSS selector (write; denylist + local model gate).
Fill inputs, textareas, or contenteditable elements with native setters and input/change events so React/Google Forms work (write; gated).
Run arbitrary JavaScript in a tab as an escape hatch (write; gated).
Reads are never blocked; writes must pass a configurable URL denylist and local Ollama gate that fails closed.
Drives the user's real, logged-in Safari browser, providing tools to list tabs, read pages, find elements, open tabs, navigate, click, fill forms, and run JavaScript, with a safety gate on write actions.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@safari-mcp What's on my active Safari tab?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
safari-mcp
An MCP server that drives your real, logged-in Safari — your cookies, your sessions, the tab you already have open — with a local model gating every write.
list_tabs · read_page · find_elements reads, never gated
open_tab · navigate · click · fill · run_js writes, gated twiceWhy not the obvious options
Route | Controls Safari? | Your logins? |
Claude in Chrome extension | no — Chromium only | ✅ |
Computer use | view-only on browsers | ✅ but read-only |
| ✅ | ❌ clean profile, signed into nothing |
AppleScript | ✅ | ✅ |
safaridriver deliberately launches an isolated automation profile, which is
the right security default and also useless if you wanted to fill in a form
behind your own account. AppleScript's do JavaScript runs inside the tab you
are already sitting in. That is the whole idea, and it is also why this server
takes safety seriously.
Related MCP server: byob
Requirements
macOS with Safari
Safari Settings → Develop → Allow JavaScript from Apple Events (this is not the "Allow remote automation" checkbox above it, which is WebDriver)
Automation permission for whatever runs the server (macOS prompts once)
Ollama with a small model pulled, for the safety gate
Python 3.11+
Install
git clone git@github.com:lionel509/Safari-MCP.git
cd Safari-MCP
UV_PROJECT_ENVIRONMENT=~/.venvs/safari-mcp uv sync
ollama pull qwen3.5:4bThe virtualenv deliberately lives outside the project directory — the checkout doubles as a folder inside an Obsidian vault, and Obsidian should never index a venv.
Register it with your MCP host:
{
"mcpServers": {
"safari-mcp": {
"type": "stdio",
"command": "/Users/you/.venvs/safari-mcp/bin/safari-mcp",
"args": [],
"env": {}
}
}
}Safety
Writes pass two layers. Reads pass neither — reading a page is never blocked.
Layer 1 — denylist. URL patterns in ~/.config/safari-mcp/config.json.
Deterministic, checked first, and not overridable by layer 2. Ships defaulting
to webmail, authentication pages, banks and brokerages, and checkout
confirmation URLs.
Layer 2 — local gate model. A small model, through Ollama, sees a compact description of the proposed action — the verb, the page URL and title, and the target element's own text — and answers ALLOW or BLOCK. It never sees the whole page: that is a privacy decision as much as a latency one, since the page is your logged-in session. It is local for the same reason.
The property that makes layer 2 safe to feed page text at all: the model can only add refusals, never remove them. A hostile page will try to talk its way through the gate. The best an injection can achieve is ALLOW — which is exactly what layer 1 has already decided it permits. Nothing is gained.
The gate fails closed. If Ollama is unreachable or the verdict is unclear, the action is refused.
Choosing a gate model
Measured on an M2 Pro. The first pass used ten obvious cases, which every local
model passed — a non-result, since a test everything passes cannot rank
anything. The set was widened to 34 (17 allow / 17 block, including six
prompt-injection dialects), and qwen3:0.6b, qwen3:1.7b and qwen3:4b were
eliminated there:
Model | 34-case | Avg | |
| 5/10 | 1.97s | Fails open — allowed a bank transfer and an injection |
| 5/10 | 0.38s | Fails closed, but refuses "Add to Cart" |
| 34/34 | 0.81s | |
| 34/34 | 1.57s | The default |
| 34/34 | 1.62s | No gain over 4b for 4× the weights |
The interesting result came from ten harder cases where the danger is implicit
— an innocuous label on a dangerous page, like BUTTON "✓" on /messages/draft
or BUTTON "Continue" on /checkout/payment, paired with innocuous twins
(BUTTON "Continue" on a Wikipedia article). There, qwen3.5:2b let the send
through and qwen3.5:4b over-refused "Next" on a Google Form.
Then the description was enriched — aria-label, the enclosing form's action,
the enclosing dialog's heading — and both models went 10/10, qwen3.5:2b
included, at 0.95s.
The binding constraint on gate accuracy is how much signal reaches it, not how large the model reading it is. A
BUTTON "✓"is unjudgeable at any parameter count;BUTTON "✓" | aria-label="Send message" | in form action="/messages/send"is trivially judgeable at 2B. Spend effort on_describe, not on a bigger model.
This is also why the gate stays local. A hosted frontier model would ship your page URLs, titles and click targets to a third party on every action, for no measurable accuracy gain over a 4B running on your own machine.
qwen3.5:4b is the default rather than the faster 2b purely on the safety
asymmetry: pre-enrichment it was the one that never missed a danger, and a
missed danger costs more than a second of latency. Set guard_model to
qwen3.5:2b if you want the speed.
Configuration
~/.config/safari-mcp/config.json, all keys optional:
Key | Default | Meaning |
| see | Regexes; any match blocks all writes on that URL |
|
| Layer 2 on/off. Layer 1 stays live regardless |
|
| Any Ollama model |
|
| |
|
| Seconds |
|
| Seconds to wait for |
Environment variables override the file: SAFARI_MCP_GUARD,
SAFARI_MCP_GUARD_MODEL, SAFARI_MCP_OLLAMA_HOST, SAFARI_MCP_GUARD_TIMEOUT,
SAFARI_MCP_LOAD_TIMEOUT, and SAFARI_MCP_DENY_EXTRA (comma-separated extra
patterns).
Notes from building it
Three things bite when you drive Safari this way, and the server absorbs all three:
Parentheses in a URL make Safari's AppleScript URL setter fail silently — the tab simply stays where it was.
navigatepercent-encodes them."Front document" drifts. A redirect, or the person switching tabs, moves it under you. Tabs are addressed by index or URL substring and re-resolved on every call.
There is no load event, so
navigateandopen_tabpollreadyStateand return the URL actually landed on, which is how you notice a redirect.
And one that only shows up in forms: Google Forms ignores element.value = x.
It is built on Closure, which listens for events. fill goes through the
element's native setter and dispatches input and change with
bubbles: true, which is also what React needs.
Tab pid looks like a stable handle and is not — same-origin tabs share a
WebContent process, so two Gmail tabs report the same pid.
Cleanup
The stock Python .gitignore here is extended with the credential patterns
(.env.*, *.key, *.pem, credentials.json, secrets.json), editor folders and
graphify-out/. CLEANUP.md lists what this project leaves behind —
in the checkout, in ~/.venvs, in ~/.config/safari-mcp, and in Ollama — and the
exact commands to remove each, keeping your secrets by default.
License
MIT
Available Tools
8 toolsclickA
Click an element. Check it with find_elements first.
Passes through the denylist and then the local safety gate, either of which may refuse — the refusal explains itself and is worth relaying verbatim rather than retrying.
Args: tab: Tab index within window 1, or a substring of its URL or title. selector: CSS selector for the element. index: Which match to click when the selector hits several. Default 0.
Inputs: tab (required), selector (required), index. Returns: result
fillA
Type a value into an input, textarea, or contenteditable element.
Sets the value through the element's native setter and then dispatches
input and change with bubbles: true. That matters: Google Forms,
React and other frameworks listen for those events and ignore a bare
assignment to .value, so a naive fill looks correct on screen and submits
empty.
Args: tab: Tab index within window 1, or a substring of its URL or title. selector: CSS selector for the field. value: The text to enter. index: Which match to fill when the selector hits several. Default 0.
Inputs: tab (required), value (required), selector (required), index. Returns: result
find_elementsA
Inspect the elements a CSS selector matches, before acting on them.
Use this to check that a selector hits what you think it does, and to read
the index you will pass to click or fill. Returns JSON with each match's
tag, id, class, visible text, href, name, type and current value.
Args: tab: Tab index within window 1, or a substring of its URL or title. selector: The CSS selector to inspect. limit: Most elements to describe. Default 20.
Inputs: tab (required), selector (required), limit. Returns: result
list_tabsA
List every open Safari tab, with the handle you use to address it.
Call this first. Every other tool takes a tab argument, and this shows you
what is addressable: a 1-based index within window 1, or any distinctive
substring of a tab's URL or title.
Returns one line per tab as [w<window>:t<index>] <title> — <url>.
Inputs: no named parameters. Returns: result
open_tabA
Open a URL in a new Safari tab and wait for it to finish loading.
Prefer this over navigate when starting a task: it leaves whatever the
person was already reading untouched. Returns the new tab's handle and its
final URL, which may differ from the one you asked for if the site
redirected.
Args: url: The URL to open. Parentheses and spaces are encoded for you.
Inputs: url (required). Returns: result
read_pageA
Read the visible text of a tab, or of one element inside it.
This is the workhorse — prefer it over screenshots. It returns rendered
text (innerText), so it reflects what a person would actually see rather
than raw markup.
Args: tab: Tab index within window 1, or a substring of its URL or title. selector: Optional CSS selector. Omit it to read the whole page. max_chars: Truncate beyond this many characters. Default 8000.
Inputs: tab (required), selector, max_chars. Returns: result
run_jsA
Run arbitrary JavaScript in a tab. The escape hatch; prefer the others.
Your code runs as a function body, so return the value you want back. The
result is JSON-encoded, so return plain data rather than DOM nodes.
Args:
tab: Tab index within window 1, or a substring of its URL or title.
code: JavaScript to execute. Use return to produce a result.
Inputs: tab (required), code (required). Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 8 tools
Each tool targets a distinct action: listing tabs, reading content, inspecting elements, opening/navigating tabs, clicking, filling, and executing JS. Even overlapping actions like open_tab vs. navigate are clearly separated by whether they open a new tab or reuse an existing one. There is no ambiguity in selecting the right tool for a task.
Most names follow a verb_noun pattern (list_tabs, read_page, find_elements, open_tab, run_js), but navigate, click, and fill are bare verbs without an object. The names are still predictable and all lowercase snake_case, but the pattern is not perfectly uniform.
Eight tools is a well-scoped set for browser automation. Each tool has a clear purpose and no redundant utilities; the count feels appropriate for the domain without being sparse or bloated.
The surface covers the core browser automation lifecycle: inspect (list_tabs, read_page, find_elements), act (click, fill), navigate (open_tab, navigate), and an escape hatch (run_js). Missing operations like back/forward, close tab, or direct URL getter are minor and can be worked around with run_js or navigate.
Maintenance
Related MCP Connectors
Let AI agents use your real, signed-in Chrome: tabs, clicks, forms, screenshots and video.
Let AI agents use any website you're logged in to. Sessions stay encrypted on your machine.
Connect AI agents to your own Chrome with scoped access, approvals, and verified task results.
Let your AI agent operate any website through a real, logged-in browser session.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to control and automate your Chrome browser directly, leveraging existing login states and configurations for tasks like content analysis, semantic search across tabs, screenshots, network monitoring, and interactive operations.12MIT
- AlicenseNot gradedqualityCmaintenanceLets AI assistants control your real Chrome browser to perform web tasks like reading pages, taking screenshots, clicking, and typing, using your existing logged-in sessions.145MIT
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to securely control a real, logged-in browser with per-site permissions, per-action approvals, secret redaction, and a tamper-evident audit log, all locally without a cloud relay.341,780 npm1MIT

K-Browserofficial
AlicenseNot gradedqualityBmaintenanceEnables local AI agents on macOS to drive the user's own signed-in Chrome through their own isolated tabs, with per-client tokens, blocked site categories, masked secrets and human hand-offs. Agents can open and navigate tabs, read pages, send real clicks and typing, inspect network requests, and hand tabs back to the user, all without touching other tabs or stealing focus.290 npm1MIT