safari-mcp 2.8.4 → 2.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -36
- package/package.json +2 -2
- package/safari.js +168 -0
package/README.md
CHANGED
|
@@ -6,19 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
# 🦁 Safari MCP
|
|
8
8
|
|
|
9
|
-
**The
|
|
9
|
+
**The browser for your coding agent.**
|
|
10
|
+
|
|
11
|
+
*Your real Safari, logged in — no Chrome, no heat, no headless.*
|
|
10
12
|
|
|
11
13
|
[](https://www.npmjs.com/package/safari-mcp)
|
|
12
14
|
[](https://www.npmjs.com/package/safari-mcp)
|
|
13
15
|
[](https://opensource.org/licenses/MIT)
|
|
14
16
|
[](https://www.apple.com/macos/)
|
|
15
|
-
[](https://github.com/achiya-automation/safari-mcp/stargazers)
|
|
16
|
-
|
|
17
|
-
[](https://registry.modelcontextprotocol.io/)
|
|
18
|
-
[](https://glama.ai/mcp/servers/g0upg7gyex)
|
|
19
|
-
[](https://github.com/punkpeye/awesome-mcp-servers)
|
|
20
|
-
[](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
|
|
21
|
-
[](https://github.com/HKUDS/CLI-Anything/pull/212)
|
|
22
17
|
|
|
23
18
|
<a href="vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code-Install_Server-0078d4?logo=visual-studio-code&logoColor=white" alt="Install in VS Code"></a>
|
|
24
19
|
<a href="https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
|
|
@@ -32,19 +27,21 @@
|
|
|
32
27
|
|
|
33
28
|
</div>
|
|
34
29
|
|
|
35
|
-
|
|
30
|
+
## ❌ Without Safari MCP
|
|
31
|
+
|
|
32
|
+
Your AI agent needs to browse. So it either:
|
|
33
|
+
|
|
34
|
+
- **Spins up Chromium via Playwright** — with no logins, no cookies, no sessions
|
|
35
|
+
- **Uses Chrome DevTools MCP** — and melts your fan running a second browser
|
|
36
|
+
- **Relies on headless scrapers** — blocked by Cloudflare, reCAPTCHA, and bot detection
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
## ✅ With Safari MCP
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Your AI drives the **Safari you're already logged into** — Gmail, GitHub, Ahrefs, Slack, banking.
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
| Playwright launches a new browser without your logins | Uses your **real Safari** with all sessions |
|
|
45
|
-
| Puppeteer requires Chrome + debug port | Just AppleScript — no extra browser |
|
|
46
|
-
| Headless browsers can't access your authenticated sessions | Gmail, GitHub, Slack — already logged in |
|
|
47
|
-
| Browser automation steals window focus | Safari stays in background, never interrupts |
|
|
42
|
+
Native WebKit. ~60% less CPU. Background operation. 80 tools. One `npx` command. macOS only.
|
|
43
|
+
|
|
44
|
+
> 📰 **Featured on HackerNoon:** [I Had to Reverse-Engineer React, Shadow DOM, and CSP to Automate Safari Without Chrome](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
|
|
48
45
|
|
|
49
46
|
---
|
|
50
47
|
|
|
@@ -128,23 +125,6 @@ cd safari-mcp && npm install
|
|
|
128
125
|
|
|
129
126
|
---
|
|
130
127
|
|
|
131
|
-
<div align="center">
|
|
132
|
-
|
|
133
|
-
### ⭐ Found Safari MCP useful?
|
|
134
|
-
|
|
135
|
-
**A star takes 2 seconds and helps other macOS developers discover this.**
|
|
136
|
-
|
|
137
|
-
[](https://github.com/achiya-automation/safari-mcp)
|
|
138
|
-
|
|
139
|
-
*4,000+ developers install Safari MCP every month. Less than 1% star it.*
|
|
140
|
-
*If it saves you Chrome heat, give it back a click.*
|
|
141
|
-
|
|
142
|
-
📰 **[Read the deep-dive on HackerNoon →](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)**
|
|
143
|
-
|
|
144
|
-
</div>
|
|
145
|
-
|
|
146
|
-
---
|
|
147
|
-
|
|
148
128
|
## Usage Workflow
|
|
149
129
|
|
|
150
130
|
The recommended pattern for AI agents using Safari MCP:
|
|
@@ -170,6 +150,9 @@ The recommended pattern for AI agents using Safari MCP:
|
|
|
170
150
|
|
|
171
151
|
## Tools (80)
|
|
172
152
|
|
|
153
|
+
<details>
|
|
154
|
+
<summary><b>Click to expand the full tool list — organized by category</b></summary>
|
|
155
|
+
|
|
173
156
|
### Navigation (4)
|
|
174
157
|
| Tool | Description |
|
|
175
158
|
|------|-------------|
|
|
@@ -341,6 +324,8 @@ The recommended pattern for AI agents using Safari MCP:
|
|
|
341
324
|
|------|-------------|
|
|
342
325
|
| `safari_run_script` | Run multiple actions in a single call (batch) |
|
|
343
326
|
|
|
327
|
+
</details>
|
|
328
|
+
|
|
344
329
|
---
|
|
345
330
|
|
|
346
331
|
## Security
|
|
@@ -576,9 +561,22 @@ Your support funds:
|
|
|
576
561
|
|
|
577
562
|
---
|
|
578
563
|
|
|
564
|
+
## What agents unlock with Safari MCP
|
|
565
|
+
|
|
566
|
+
When an AI agent drives Safari MCP, it gets things a headless browser can't:
|
|
567
|
+
|
|
568
|
+
- **Real authenticated sessions** — Gmail, GitHub, Ahrefs, Slack, banking dashboards are all already logged in
|
|
569
|
+
- **Framework-aware form filling** — `safari_fill_and_submit` calls React/Vue/Angular setters natively, no guessing whether `input` events fired
|
|
570
|
+
- **Background operation** — the agent works in parallel while you keep using your Mac
|
|
571
|
+
- **One MCP call per workflow** — `safari_run_script` batches navigation + clicks + extraction into a single roundtrip
|
|
572
|
+
|
|
573
|
+
The pattern holds across models: **drive the browser the human already trusts** — you inherit logins, cookies, extensions, and the user's exact environment in one step.
|
|
574
|
+
|
|
575
|
+
---
|
|
576
|
+
|
|
579
577
|
## Community
|
|
580
578
|
|
|
581
|
-
**
|
|
579
|
+
**6,000+ monthly npm downloads** — developers are building AI agents on macOS with Safari MCP.
|
|
582
580
|
|
|
583
581
|
- [GitHub Discussions](https://github.com/achiya-automation/safari-mcp/discussions) — ask questions, share use cases
|
|
584
582
|
- [Issues](https://github.com/achiya-automation/safari-mcp/issues) — bug reports and feature requests
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.9",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"ws": "^8.19.0"
|
|
77
77
|
},
|
|
78
78
|
"overrides": {
|
|
79
|
-
"hono": "^4.12.
|
|
79
|
+
"hono": "^4.12.14",
|
|
80
80
|
"@hono/node-server": "^1.19.13"
|
|
81
81
|
}
|
|
82
82
|
}
|
package/safari.js
CHANGED
|
@@ -1742,6 +1742,174 @@ export async function replaceEditorContent({ text }) {
|
|
|
1742
1742
|
.replace(/\n/g, '\\n')
|
|
1743
1743
|
.replace(/\r/g, '');
|
|
1744
1744
|
|
|
1745
|
+
// PREFLIGHT: Two-channel Monaco sync — update the model (visual) AND call
|
|
1746
|
+
// the React wrapper's onChange (state). Airtable wraps Monaco in a React
|
|
1747
|
+
// component whose "Finish editing" save reads from React state, not from
|
|
1748
|
+
// the Monaco model. So setValue alone is "visually correct, stale on save".
|
|
1749
|
+
// We walk the React fiber up from .monaco-editor until we find a component
|
|
1750
|
+
// whose memoizedProps contain an `onChange` function + a `value` field,
|
|
1751
|
+
// then call onChange(text) to sync React. Works for Airtable-style embeds
|
|
1752
|
+
// and is a no-op on plain Monaco (VS Code web, GitHub) where setValue
|
|
1753
|
+
// already covers the state.
|
|
1754
|
+
const preflightFirstLine = text.split('\n')[0].slice(0, 20).replace(/'/g, "\\'");
|
|
1755
|
+
const monacoPreflight = await runJS(
|
|
1756
|
+
`(function(){
|
|
1757
|
+
var m = (typeof monaco !== 'undefined') ? monaco : window.monaco;
|
|
1758
|
+
if (!m || !m.editor) return JSON.stringify({kind:'not-monaco'});
|
|
1759
|
+
var hasTextarea = !!document.querySelector('.monaco-editor textarea.inputarea');
|
|
1760
|
+
var models = []; try { models = m.editor.getModels() || []; } catch(e){}
|
|
1761
|
+
if (!models.length) return JSON.stringify({kind:'no-model', hasTextarea: hasTextarea});
|
|
1762
|
+
|
|
1763
|
+
// Locate the React wrapper component that owns this Monaco embed.
|
|
1764
|
+
// Prefer an editable wrapper (isReadOnly !== true); fall back to any
|
|
1765
|
+
// wrapper if only a read-only preview exists (Airtable before Edit).
|
|
1766
|
+
function findFiber(el) {
|
|
1767
|
+
if (!el) return null;
|
|
1768
|
+
var ks = Object.keys(el);
|
|
1769
|
+
for (var j = 0; j < ks.length; j++) {
|
|
1770
|
+
if (ks[j].indexOf('__reactFiber') === 0) return el[ks[j]];
|
|
1771
|
+
}
|
|
1772
|
+
return null;
|
|
1773
|
+
}
|
|
1774
|
+
function findWrapperWithOnChange(fiber) {
|
|
1775
|
+
var c = fiber;
|
|
1776
|
+
for (var i = 0; i < 40 && c; i++) {
|
|
1777
|
+
var p = c.memoizedProps;
|
|
1778
|
+
if (p && typeof p.onChange === 'function' && ('value' in p)) return c;
|
|
1779
|
+
c = c.return;
|
|
1780
|
+
}
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
var els = document.querySelectorAll('.monaco-editor');
|
|
1784
|
+
var editableWrapper = null, anyWrapper = null;
|
|
1785
|
+
for (var i = els.length - 1; i >= 0; i--) {
|
|
1786
|
+
var f = findFiber(els[i].parentElement);
|
|
1787
|
+
if (!f) continue;
|
|
1788
|
+
var w = findWrapperWithOnChange(f);
|
|
1789
|
+
if (!w) continue;
|
|
1790
|
+
if (!anyWrapper) anyWrapper = w;
|
|
1791
|
+
if (w.memoizedProps.isReadOnly !== true) { editableWrapper = w; break; }
|
|
1792
|
+
}
|
|
1793
|
+
var wrapper = editableWrapper || anyWrapper;
|
|
1794
|
+
|
|
1795
|
+
// Step 1: Monaco model setValue — updates visual + fires onDidChangeModelContent
|
|
1796
|
+
try { models[models.length - 1].setValue('${safeText}'); }
|
|
1797
|
+
catch(e) { return JSON.stringify({kind:'setValue-err', hasTextarea: hasTextarea, err: String(e && e.message)}); }
|
|
1798
|
+
|
|
1799
|
+
// Step 2: Sync React state via wrapper.onChange (if wrapper found).
|
|
1800
|
+
// onChange may be silently guarded (e.g. Airtable's readOnly check).
|
|
1801
|
+
// We only treat sync as real when the wrapper's value prop actually
|
|
1802
|
+
// changes OR a sibling/parent expression state reflects the update
|
|
1803
|
+
// on the next render tick.
|
|
1804
|
+
var reactSynced = false;
|
|
1805
|
+
var reactGuarded = false;
|
|
1806
|
+
if (wrapper) {
|
|
1807
|
+
var prevValue = wrapper.memoizedProps.value;
|
|
1808
|
+
try {
|
|
1809
|
+
wrapper.memoizedProps.onChange('${safeText}');
|
|
1810
|
+
// Re-fetch the fiber's memoizedProps (may have been swapped by React)
|
|
1811
|
+
var after = wrapper.alternate ? wrapper.alternate.memoizedProps : wrapper.memoizedProps;
|
|
1812
|
+
var nowValue = (after && 'value' in after) ? after.value : wrapper.memoizedProps.value;
|
|
1813
|
+
reactSynced = (nowValue !== prevValue);
|
|
1814
|
+
// If value didn't change, onChange was a no-op (guarded by isReadOnly
|
|
1815
|
+
// or permission check). Signal this so caller falls back to native paste.
|
|
1816
|
+
reactGuarded = !reactSynced;
|
|
1817
|
+
} catch(e) { reactSynced = false; reactGuarded = true; }
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Step 3: Verify DOM reflects the new content
|
|
1821
|
+
var firstDom = document.querySelector('.monaco-editor .view-line');
|
|
1822
|
+
var domTxt = firstDom ? firstDom.textContent : '';
|
|
1823
|
+
var expected = '${preflightFirstLine}';
|
|
1824
|
+
var domOk = expected.length < 5 || domTxt.indexOf(expected) !== -1;
|
|
1825
|
+
|
|
1826
|
+
return JSON.stringify({
|
|
1827
|
+
kind: 'monaco',
|
|
1828
|
+
domOk: domOk,
|
|
1829
|
+
reactSynced: reactSynced,
|
|
1830
|
+
reactGuarded: reactGuarded,
|
|
1831
|
+
hasWrapper: !!wrapper,
|
|
1832
|
+
editable: !!editableWrapper,
|
|
1833
|
+
hasTextarea: hasTextarea,
|
|
1834
|
+
models: models.length
|
|
1835
|
+
});
|
|
1836
|
+
})()`
|
|
1837
|
+
);
|
|
1838
|
+
|
|
1839
|
+
let mpParsed = null;
|
|
1840
|
+
try { mpParsed = JSON.parse(monacoPreflight); } catch(e) {}
|
|
1841
|
+
|
|
1842
|
+
// Fully synced: Monaco model + React state both updated.
|
|
1843
|
+
if (mpParsed && mpParsed.kind === 'monaco' && mpParsed.domOk && mpParsed.reactSynced) {
|
|
1844
|
+
return 'Monaco(model+react): replaced ' + text.split('\n').length + ' lines';
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
// Plain Monaco embed (no React wrapper) — setValue is sufficient.
|
|
1848
|
+
if (mpParsed && mpParsed.kind === 'monaco' && mpParsed.domOk && !mpParsed.hasWrapper) {
|
|
1849
|
+
return 'Monaco(model): replaced ' + text.split('\n').length + ' lines';
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Airtable-style embed where React fiber sync failed (wrapper.onChange was
|
|
1853
|
+
// a no-op, or the site guards writes by permission/readOnly). Fall back to
|
|
1854
|
+
// native clipboard paste via CGEvent: forces readOnly=false first, activates
|
|
1855
|
+
// Safari, focuses the textarea, and sends Cmd+A/Cmd+V targeted to the window.
|
|
1856
|
+
if (mpParsed && mpParsed.kind === 'monaco' && mpParsed.hasTextarea) {
|
|
1857
|
+
const savedFrontApp = await saveFrontmostApp();
|
|
1858
|
+
try {
|
|
1859
|
+
// Step 1: Install editor capture hook (if not already) + try to find visible editable editor
|
|
1860
|
+
await runJS(
|
|
1861
|
+
`(function(){
|
|
1862
|
+
if (!window.__mcpEditorHook) {
|
|
1863
|
+
window.__mcpEditorHook = true;
|
|
1864
|
+
window.__mcpCapturedEditors = [];
|
|
1865
|
+
try { monaco.editor.onDidCreateEditor(function(e){window.__mcpCapturedEditors.push(e);}); } catch(e){}
|
|
1866
|
+
}
|
|
1867
|
+
// Force readOnly=false on all existing editors (harmless if already false)
|
|
1868
|
+
try {
|
|
1869
|
+
(window.__mcpCapturedEditors || []).forEach(function(e){
|
|
1870
|
+
try { if (e.updateOptions) e.updateOptions({readOnly: false}); } catch(_){}
|
|
1871
|
+
});
|
|
1872
|
+
} catch(e){}
|
|
1873
|
+
return 'hook-installed';
|
|
1874
|
+
})()`
|
|
1875
|
+
);
|
|
1876
|
+
// Step 2: Activate Safari to frontmost — required for CGEvent keyboard to reach web content
|
|
1877
|
+
await _helperActivateApp("com.apple.Safari");
|
|
1878
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1879
|
+
// Step 3: Focus Monaco's input textarea
|
|
1880
|
+
await runJS(`(function(){var t=document.querySelector('.monaco-editor textarea.inputarea');if(t){t.focus();return 'focused';}return 'no-textarea';})()`);
|
|
1881
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1882
|
+
// Step 4: Cmd+A + Cmd+V via GLOBAL CGEvent (cghidEventTap) — reaches web content
|
|
1883
|
+
// reliably since Safari is frontmost
|
|
1884
|
+
await _helperNativeKeyboard(0, ["cmd"], 0); // Cmd+A global
|
|
1885
|
+
await new Promise(r => setTimeout(r, 100));
|
|
1886
|
+
// Write clipboard + paste via global CGEvent (not windowed)
|
|
1887
|
+
await _acquireClipboardLock();
|
|
1888
|
+
try {
|
|
1889
|
+
const savedClip = await _saveClipboard();
|
|
1890
|
+
await new Promise((resolve, reject) => {
|
|
1891
|
+
const proc = spawn("pbcopy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
1892
|
+
proc.stdin.write(text);
|
|
1893
|
+
proc.stdin.end();
|
|
1894
|
+
proc.on("close", resolve);
|
|
1895
|
+
proc.on("error", reject);
|
|
1896
|
+
});
|
|
1897
|
+
await _helperNativeKeyboard(9, ["cmd"], 0); // Cmd+V global
|
|
1898
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1899
|
+
await _restoreClipboard(savedClip);
|
|
1900
|
+
} finally {
|
|
1901
|
+
_releaseClipboardLock();
|
|
1902
|
+
}
|
|
1903
|
+
await new Promise(r => setTimeout(r, 300));
|
|
1904
|
+
// Restore original front app
|
|
1905
|
+
if (savedFrontApp) await restoreFocusIfStolen(savedFrontApp);
|
|
1906
|
+
return 'Monaco(native-paste): replaced ' + text.split('\n').length + ' lines';
|
|
1907
|
+
} catch(e) {
|
|
1908
|
+
if (savedFrontApp) await restoreFocusIfStolen(savedFrontApp).catch(() => {});
|
|
1909
|
+
// Fall through to remaining editor-type checks
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1745
1913
|
const result = await runJS(
|
|
1746
1914
|
`(function(){
|
|
1747
1915
|
// Monaco editor (Airtable, VS Code web, GitHub)
|