safari-mcp 2.1.4 → 2.1.6

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 CHANGED
@@ -9,13 +9,14 @@
9
9
  [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green)](https://nodejs.org/)
10
10
  [![macOS](https://img.shields.io/badge/macOS-only-blue)](https://www.apple.com/macos/)
11
11
  [![MCP](https://img.shields.io/badge/MCP-compatible-purple)](https://modelcontextprotocol.io)
12
+ [![CI](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
12
13
  [![npm downloads](https://img.shields.io/npm/dm/safari-mcp)](https://www.npmjs.com/package/safari-mcp)
13
14
  [![GitHub stars](https://img.shields.io/github/stars/achiya-automation/safari-mcp?style=social)](https://github.com/achiya-automation/safari-mcp/stargazers)
14
15
  [![achiya-automation/safari-mcp MCP server](https://glama.ai/mcp/servers/achiya-automation/safari-mcp/badges/score.svg)](https://glama.ai/mcp/servers/achiya-automation/safari-mcp)
15
16
 
16
17
  **80 tools** · **Zero dependencies** · **~5ms per command** · **60% less CPU than Chrome**
17
18
 
18
- [Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture)
19
+ [Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Examples](examples/) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture) · [Changelog](CHANGELOG.md)
19
20
 
20
21
  ![Safari MCP Demo](https://github.com/achiya-automation/safari-mcp/releases/download/v2.1.2/safari-mcp-showcase.gif)
21
22
 
@@ -63,6 +64,12 @@
63
64
 
64
65
  ### Install
65
66
 
67
+ **Option A — npm (recommended):**
68
+ ```bash
69
+ npm install -g safari-mcp
70
+ ```
71
+
72
+ **Option B — from source:**
66
73
  ```bash
67
74
  git clone https://github.com/achiya-automation/safari-mcp.git
68
75
  cd safari-mcp
@@ -447,7 +454,9 @@ Safari MCP works with any MCP-compatible client:
447
454
 
448
455
  ## Contributing
449
456
 
450
- PRs welcome! The codebase is two files:
457
+ PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
458
+
459
+ The codebase is two files:
451
460
  - `safari.js` — Safari automation layer (AppleScript + JavaScript)
452
461
  - `index.js` — MCP server with tool definitions
453
462
 
@@ -1548,6 +1548,8 @@ async function handleCommand(type, payload) {
1548
1548
  const _sessionTabCache = new Map();
1549
1549
  const TAB_CACHE_MS = 3000; // Re-verify tab URL match every 3s
1550
1550
  const _DEFAULT_SESSION = "__default__"; // Fallback for commands without sessionId
1551
+ const SESSION_MAX_AGE_MS = 5 * 60 * 1000; // 5 min — prune stale sessions
1552
+ const MAX_SESSIONS = 50; // Hard cap on session cache size
1551
1553
 
1552
1554
  function _getSessionCache(sessionId) {
1553
1555
  const sid = sessionId || _DEFAULT_SESSION;
@@ -1557,6 +1559,29 @@ function _getSessionCache(sessionId) {
1557
1559
  return _sessionTabCache.get(sid);
1558
1560
  }
1559
1561
 
1562
+ // Prune stale sessions — runs every 60s
1563
+ function _pruneSessionCache() {
1564
+ const now = Date.now();
1565
+ for (const [sid, cache] of _sessionTabCache) {
1566
+ if (sid === _DEFAULT_SESSION) continue;
1567
+ // Remove sessions with no active tab that haven't been used in 5 min
1568
+ if (!cache.tabId && (now - cache.time) > SESSION_MAX_AGE_MS) {
1569
+ _sessionTabCache.delete(sid);
1570
+ }
1571
+ }
1572
+ // Hard cap: if still too many, remove oldest
1573
+ if (_sessionTabCache.size > MAX_SESSIONS) {
1574
+ const sorted = [..._sessionTabCache.entries()]
1575
+ .filter(([sid]) => sid !== _DEFAULT_SESSION)
1576
+ .sort((a, b) => a[1].time - b[1].time);
1577
+ while (_sessionTabCache.size > MAX_SESSIONS && sorted.length) {
1578
+ const [sid] = sorted.shift();
1579
+ _sessionTabCache.delete(sid);
1580
+ }
1581
+ }
1582
+ }
1583
+ setInterval(_pruneSessionCache, 60000);
1584
+
1560
1585
  function _setSessionTab(sessionId, tabId, tabUrl) {
1561
1586
  const cache = _getSessionCache(sessionId);
1562
1587
  cache.tabId = tabId;
package/index.js CHANGED
@@ -46,6 +46,77 @@ try {
46
46
  // ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
47
47
  const SESSION_ID = randomUUID().slice(0, 8);
48
48
 
49
+ // ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
50
+ const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
51
+ const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
52
+ const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000", 10);
53
+
54
+ // Track tabs opened by THIS session (index → {url, openedAt})
55
+ const _openedTabs = new Map();
56
+
57
+ function _trackTab(tabIndex, url) {
58
+ _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now() });
59
+ }
60
+
61
+ function _untrackTab(tabIndex) {
62
+ _openedTabs.delete(tabIndex);
63
+ }
64
+
65
+ // Close all MCP-opened tabs on process exit
66
+ async function _cleanupTabs() {
67
+ if (_openedTabs.size === 0) return;
68
+ console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
69
+ const indices = [..._openedTabs.keys()].sort((a, b) => b - a);
70
+ for (const idx of indices) {
71
+ try {
72
+ safari.setActiveTabIndex(idx);
73
+ await safari.closeTab();
74
+ } catch {}
75
+ }
76
+ _openedTabs.clear();
77
+ }
78
+
79
+ // Periodic memory check — if WebKit exceeds limit, close oldest MCP tabs
80
+ let _memoryCheckTimer = null;
81
+ function _startMemoryMonitor() {
82
+ _memoryCheckTimer = setInterval(async () => {
83
+ try {
84
+ const psOut = execFileSync("bash", ["-c",
85
+ "ps aux | grep -i 'WebKit\\|WebContent' | grep -v grep | awk '{sum+=$6} END {print sum/1024}'"
86
+ ], { encoding: "utf8" }).trim();
87
+ const webkitMB = parseFloat(psOut) || 0;
88
+ if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
89
+ console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
90
+ let oldestIdx = null, oldestTime = Infinity;
91
+ for (const [idx, info] of _openedTabs) {
92
+ if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
93
+ }
94
+ if (oldestIdx !== null) {
95
+ try {
96
+ safari.setActiveTabIndex(oldestIdx);
97
+ await safari.closeTab();
98
+ } catch {}
99
+ _untrackTab(oldestIdx);
100
+ }
101
+ }
102
+ } catch {}
103
+ }, MEMORY_CHECK_INTERVAL_MS);
104
+ _memoryCheckTimer.unref();
105
+ }
106
+
107
+ // Cleanup on exit
108
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
109
+ process.on(sig, async () => {
110
+ await _cleanupTabs();
111
+ process.exit(0);
112
+ });
113
+ }
114
+ process.on("exit", () => {
115
+ if (_openedTabs.size > 0) {
116
+ console.error(`[Safari MCP] Exit: ${_openedTabs.size} tabs were tracked for cleanup`);
117
+ }
118
+ });
119
+
49
120
  // ========== EXTENSION FOCUS SAFETY ==========
50
121
  // When SAFARI_PROFILE is set, the extension's browser.scripting.executeScript()
51
122
  // can steal window focus in Safari (bringing the automation window to front).
@@ -894,6 +965,22 @@ server.tool(
894
965
  "Open a new tab, optionally with a URL",
895
966
  { url: z.string().optional().describe("URL to open (empty for blank tab)") },
896
967
  async ({ url }) => {
968
+ // Enforce tab limit — close oldest MCP tab if at max
969
+ if (_openedTabs.size >= MAX_TABS) {
970
+ let oldestIdx = null, oldestTime = Infinity;
971
+ for (const [idx, info] of _openedTabs) {
972
+ if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
973
+ }
974
+ if (oldestIdx !== null) {
975
+ console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
976
+ try {
977
+ safari.setActiveTabIndex(oldestIdx);
978
+ await safari.closeTab();
979
+ } catch {}
980
+ _untrackTab(oldestIdx);
981
+ }
982
+ }
983
+
897
984
  const result = await extensionOrFallback(
898
985
  "new_tab", { url },
899
986
  () => safari.newTab(url)
@@ -901,6 +988,7 @@ server.tool(
901
988
  // Sync safari.js tracking when extension handled new_tab
902
989
  if (result?.tabIndex) {
903
990
  safari.setActiveTabIndex(result.tabIndex);
991
+ _trackTab(result.tabIndex, url);
904
992
  }
905
993
  if (result?.url) {
906
994
  safari.setActiveTabURL(result.url);
@@ -914,7 +1002,9 @@ server.tool(
914
1002
  "Close the current tab",
915
1003
  {},
916
1004
  async () => {
1005
+ const activeIdx = safari.getActiveTabIndex();
917
1006
  const result = await extensionOrFallback("close_tab", {}, () => safari.closeTab());
1007
+ if (activeIdx !== null) _untrackTab(activeIdx);
918
1008
  return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
919
1009
  }
920
1010
  );
@@ -1720,5 +1810,6 @@ server.tool(
1720
1810
 
1721
1811
  // ========== START SERVER ==========
1722
1812
 
1813
+ _startMemoryMonitor();
1723
1814
  const transport = new StdioServerTransport();
1724
1815
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
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
@@ -1152,6 +1152,9 @@ export async function fill({ selector, value, ref }) {
1152
1152
  `var pm=el.closest('.ProseMirror')||el.querySelector('.ProseMirror');if(pm){try{var v=null;if(pm.pmViewDesc&&pm.pmViewDesc.view)v=pm.pmViewDesc.view;else if(pm.cmView&&pm.cmView.view)v=pm.cmView.view;else{var keys=Object.keys(pm);for(var ki=0;ki<keys.length;ki++){var o=pm[keys[ki]];if(o&&o.state&&o.dispatch){v=o;break;}}}if(v&&v.state&&v.dispatch){var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));v.focus();return 'Filled CE (ProseMirror append)';}else{var tr=v.state.tr;tr.replaceWith(0,doc.content.size,v.state.schema.text('${val}'));v.dispatch(tr);v.focus();return 'Filled CE (ProseMirror replace)';}}}catch(e){}}` +
1153
1153
  // Closure/Medium detection — signal for native paste
1154
1154
  `var isClosure=Object.keys(el).some(function(k){return k.startsWith('closure_uid_');})||location.hostname.includes('medium.com');if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
1155
+ // Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
1156
+ // that don't respond to execCommand but DO handle paste events
1157
+ `try{el.focus();var sel2=window.getSelection();if(sel2.rangeCount){var rng=document.createRange();rng.selectNodeContents(el);sel2.removeAllRanges();sel2.addRange(rng);}var dt=new DataTransfer();dt.setData('text/plain','${val}');var pe=new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:dt});var handled=!el.dispatchEvent(pe);if(handled||el.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (synthetic paste)';}}catch(ep){}` +
1155
1158
  // Default contenteditable: selectAll+delete+insert (safer than textContent='')
1156
1159
  `document.execCommand('selectAll');document.execCommand('delete');document.execCommand('insertText',false,'${val}');el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));return 'Filled contenteditable';}` +
1157
1160
  // Standard input/textarea with _valueTracker