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 +11 -2
- package/extension/background.js +25 -0
- package/index.js +91 -0
- package/package.json +1 -1
- package/safari.js +3 -0
package/README.md
CHANGED
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
[](https://nodejs.org/)
|
|
10
10
|
[](https://www.apple.com/macos/)
|
|
11
11
|
[](https://modelcontextprotocol.io)
|
|
12
|
+
[](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
|
|
12
13
|
[](https://www.npmjs.com/package/safari-mcp)
|
|
13
14
|
[](https://github.com/achiya-automation/safari-mcp/stargazers)
|
|
14
15
|
[](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
|

|
|
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!
|
|
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
|
|
package/extension/background.js
CHANGED
|
@@ -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.
|
|
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
|