safari-mcp 2.8.4 → 2.8.8

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.
Files changed (3) hide show
  1. package/README.md +34 -36
  2. package/package.json +1 -1
  3. package/safari.js +168 -0
package/README.md CHANGED
@@ -6,19 +6,14 @@
6
6
 
7
7
  # 🦁 Safari MCP
8
8
 
9
- **The only MCP server for Safari — native browser automation for AI agents.**
9
+ **The browser for your coding agent.**
10
+
11
+ *Your real Safari, logged in — no Chrome, no heat, no headless.*
10
12
 
11
13
  [![npm version](https://img.shields.io/npm/v/safari-mcp)](https://www.npmjs.com/package/safari-mcp)
12
14
  [![npm downloads](https://img.shields.io/npm/dm/safari-mcp)](https://www.npmjs.com/package/safari-mcp)
13
15
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
14
16
  [![macOS](https://img.shields.io/badge/macOS-only-blue)](https://www.apple.com/macos/)
15
- [![GitHub stars](https://img.shields.io/github/stars/achiya-automation/safari-mcp?style=social)](https://github.com/achiya-automation/safari-mcp/stargazers)
16
-
17
- [![MCP Registry](https://img.shields.io/badge/MCP-Registry-purple?logo=anthropic)](https://registry.modelcontextprotocol.io/)
18
- [![Glama](https://img.shields.io/badge/Glama-Listed-7c3aed)](https://glama.ai/mcp/servers/g0upg7gyex)
19
- [![Awesome MCP](https://img.shields.io/badge/Awesome%20MCP-Featured-ff69b4)](https://github.com/punkpeye/awesome-mcp-servers)
20
- [![Featured on HackerNoon](https://img.shields.io/badge/Featured%20on-HackerNoon-00FE9A?logo=hackernoon&logoColor=black)](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
21
- [![Featured in CLI-Anything](https://img.shields.io/badge/Featured%20in-CLI--Anything-blueviolet)](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
- > **TL;DR:** Use your real Safari with all your logins, cookies, and sessions. No headless browsers, no Chrome, no Puppeteer. Just native AppleScript + JavaScript on macOS — 60% less CPU/heat on Apple Silicon.
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
- > 📰 **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) — the three hardest technical problems behind Safari MCP and the code that solved them.
38
+ ## ✅ With Safari MCP
38
39
 
39
- ### 🤔 Why not just use Playwright or Chrome DevTools MCP?
40
+ Your AI drives the **Safari you're already logged into** — Gmail, GitHub, Ahrefs, Slack, banking.
40
41
 
41
- | Problem | Safari MCP Solution |
42
- |---------|-------------------|
43
- | Chrome DevTools MCP heats up your Mac | Native WebKit — ~60% less CPU |
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
- [![Star on GitHub](https://img.shields.io/github/stars/achiya-automation/safari-mcp?style=for-the-badge&logo=github&color=yellow&label=Star%20this%20repo)](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
- **2,000+ monthly npm downloads** — developers are using Safari MCP to build AI agents on macOS.
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.4",
3
+ "version": "2.8.8",
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",
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)