safari-mcp 2.6.0 → 2.7.0
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/extension/background.js +22 -3
- package/index.js +95 -46
- package/package.json +12 -4
- package/safari.js +188 -120
- package/xcode/Safari MCP/Safari MCP.xcodeproj/project.pbxproj +0 -991
- package/xcode/Safari MCP/Safari MCP.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AccentColor.colorset/Contents.json +0 -11
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/Contents.json +0 -98
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-128@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-16@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-256@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-32@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/mac-icon-512@2x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/AppIcon.appiconset/universal-icon-1024@1x.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/Contents.json +0 -6
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/LargeIcon.imageset/Contents.json +0 -21
- package/xcode/Safari MCP/Shared (App)/Assets.xcassets/LargeIcon.imageset/icon-128.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Resources/Base.lproj/Main.html +0 -20
- package/xcode/Safari MCP/Shared (App)/Resources/Icon.png +0 -0
- package/xcode/Safari MCP/Shared (App)/Resources/Script.js +0 -24
- package/xcode/Safari MCP/Shared (App)/Resources/Style.css +0 -61
- package/xcode/Safari MCP/Shared (App)/ViewController.swift +0 -81
- package/xcode/Safari MCP/Shared (Extension)/SafariWebExtensionHandler.swift +0 -42
- package/xcode/Safari MCP/iOS (App)/AppDelegate.swift +0 -24
- package/xcode/Safari MCP/iOS (App)/Base.lproj/LaunchScreen.storyboard +0 -36
- package/xcode/Safari MCP/iOS (App)/Base.lproj/Main.storyboard +0 -38
- package/xcode/Safari MCP/iOS (App)/Info.plist +0 -27
- package/xcode/Safari MCP/iOS (App)/SceneDelegate.swift +0 -18
- package/xcode/Safari MCP/iOS (Extension)/Info.plist +0 -13
- package/xcode/Safari MCP/macOS (App)/AppDelegate.swift +0 -21
- package/xcode/Safari MCP/macOS (App)/Base.lproj/Main.storyboard +0 -124
- package/xcode/Safari MCP/macOS (App)/Info.plist +0 -8
- package/xcode/Safari MCP/macOS (Extension)/Info.plist +0 -13
package/extension/background.js
CHANGED
|
@@ -37,6 +37,7 @@ browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
|
37
37
|
if (pollAbort) { try { pollAbort.abort(); } catch {} pollAbort = null; }
|
|
38
38
|
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
39
39
|
_reconnectDelay = 3000;
|
|
40
|
+
_stopHeartbeat();
|
|
40
41
|
updateBadge("OFF");
|
|
41
42
|
} else {
|
|
42
43
|
updateBadge("");
|
|
@@ -102,6 +103,7 @@ async function connect() {
|
|
|
102
103
|
isConnected = true;
|
|
103
104
|
_reconnectDelay = 3000; // Reset backoff on success
|
|
104
105
|
updateBadge("ON");
|
|
106
|
+
_startHeartbeat(); // Keep service worker alive between polls
|
|
105
107
|
pollForCommands();
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
@@ -2108,10 +2110,25 @@ async function waitForTabSettled(tabId, timeout = 3000) {
|
|
|
2108
2110
|
// No else needed — if not loading, page is already settled
|
|
2109
2111
|
}
|
|
2110
2112
|
|
|
2111
|
-
// ========== KEEP-ALIVE VIA ALARMS ==========
|
|
2113
|
+
// ========== KEEP-ALIVE VIA ALARMS + HEARTBEAT ==========
|
|
2112
2114
|
// Safari kills service workers after ~30s of inactivity.
|
|
2113
|
-
//
|
|
2114
|
-
//
|
|
2115
|
+
// Three-layer strategy:
|
|
2116
|
+
// 1. Active fetch() in pollForCommands() keeps the worker alive while connected
|
|
2117
|
+
// 2. Storage write every 20s keeps the worker alive between polls (Safari counts storage access as activity)
|
|
2118
|
+
// 3. browser.alarms (1 min minimum) re-wakes the worker if it was terminated
|
|
2119
|
+
let _heartbeatTimer = null;
|
|
2120
|
+
function _startHeartbeat() {
|
|
2121
|
+
if (_heartbeatTimer) return;
|
|
2122
|
+
_heartbeatTimer = setInterval(() => {
|
|
2123
|
+
if (_enabled) {
|
|
2124
|
+
browser.storage.local.set({ _heartbeat: Date.now() }).catch(() => {});
|
|
2125
|
+
}
|
|
2126
|
+
}, 20000); // Every 20s — keeps service worker alive between alarm intervals
|
|
2127
|
+
}
|
|
2128
|
+
function _stopHeartbeat() {
|
|
2129
|
+
if (_heartbeatTimer) { clearInterval(_heartbeatTimer); _heartbeatTimer = null; }
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2115
2132
|
browser.alarms.create("keepalive", { periodInMinutes: 1 });
|
|
2116
2133
|
browser.alarms.onAlarm.addListener((alarm) => {
|
|
2117
2134
|
if (alarm.name === "keepalive" || alarm.name === "reconnect") {
|
|
@@ -2119,6 +2136,8 @@ browser.alarms.onAlarm.addListener((alarm) => {
|
|
|
2119
2136
|
if (!isConnected && _enabled && !_reconnectTimer) {
|
|
2120
2137
|
scheduleReconnect();
|
|
2121
2138
|
}
|
|
2139
|
+
// Restart heartbeat in case it was lost on worker restart
|
|
2140
|
+
if (_enabled && !_heartbeatTimer) _startHeartbeat();
|
|
2122
2141
|
}
|
|
2123
2142
|
});
|
|
2124
2143
|
|
package/index.js
CHANGED
|
@@ -68,12 +68,23 @@ const _ownedTabURLs = new Set();
|
|
|
68
68
|
function _isURLOwned(url) {
|
|
69
69
|
if (!url) return false;
|
|
70
70
|
if (_ownedTabURLs.has(url)) return true;
|
|
71
|
-
// Match ignoring query params / fragments (URL may change slightly after load)
|
|
72
|
-
const
|
|
71
|
+
// Match ignoring query params / fragments / trailing slashes (URL may change slightly after load)
|
|
72
|
+
const normalize = (u) => u.split('?')[0].split('#')[0].replace(/\/+$/, '');
|
|
73
|
+
const urlBase = normalize(url);
|
|
73
74
|
for (const owned of _ownedTabURLs) {
|
|
74
|
-
const ownedBase = owned
|
|
75
|
+
const ownedBase = normalize(owned);
|
|
75
76
|
if (urlBase === ownedBase) return true;
|
|
76
77
|
}
|
|
78
|
+
// Same-origin redirect: if a tab navigated from an owned URL to a different path
|
|
79
|
+
// on the same origin (e.g. /login/device → /login/device/select_account), it's still ours
|
|
80
|
+
try {
|
|
81
|
+
const urlOrigin = new URL(url).origin;
|
|
82
|
+
for (const owned of _ownedTabURLs) {
|
|
83
|
+
try {
|
|
84
|
+
if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalize(owned))) return true;
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
77
88
|
return false;
|
|
78
89
|
}
|
|
79
90
|
|
|
@@ -122,48 +133,73 @@ async function _cleanupTabs() {
|
|
|
122
133
|
_openedTabs.clear();
|
|
123
134
|
}
|
|
124
135
|
|
|
125
|
-
// Periodic memory check —
|
|
136
|
+
// Periodic memory check — proactive monitoring with warning + action thresholds
|
|
137
|
+
const WEBKIT_WARNING_THRESHOLD_MB = Math.round(WEBKIT_MEMORY_LIMIT_MB * 0.7); // 70% = warning
|
|
126
138
|
let _memoryCheckTimer = null;
|
|
139
|
+
let _lastMemoryWarningTime = 0;
|
|
140
|
+
|
|
141
|
+
function _getWebKitMemoryMB() {
|
|
142
|
+
try {
|
|
143
|
+
const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
|
|
144
|
+
if (!pids) return 0;
|
|
145
|
+
const pidList = pids.split("\n").join(",");
|
|
146
|
+
const psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
|
|
147
|
+
const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
|
|
148
|
+
return totalKB / 1024;
|
|
149
|
+
} catch { return 0; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function _closeOldestMCPTab() {
|
|
153
|
+
let oldestIdx = null, oldestTime = Infinity;
|
|
154
|
+
for (const [idx, info] of _openedTabs) {
|
|
155
|
+
if (info.openedAt < oldestTime) { oldestTime = info.openedAt; oldestIdx = idx; }
|
|
156
|
+
}
|
|
157
|
+
if (oldestIdx !== null) {
|
|
158
|
+
try {
|
|
159
|
+
const info = _openedTabs.get(oldestIdx);
|
|
160
|
+
if (info?.url) {
|
|
161
|
+
const tabs = await safari.listTabs();
|
|
162
|
+
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
163
|
+
const match = parsed.find(t => t.url === info.url);
|
|
164
|
+
if (match) {
|
|
165
|
+
safari.setActiveTabIndex(match.index);
|
|
166
|
+
await safari.closeTab();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {}
|
|
170
|
+
_untrackTab(oldestIdx);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
127
174
|
function _startMemoryMonitor() {
|
|
175
|
+
const checkInterval = Math.min(MEMORY_CHECK_INTERVAL_MS, 30000); // Max 30s between checks
|
|
128
176
|
_memoryCheckTimer = setInterval(async () => {
|
|
129
177
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
psOut = String(totalKB / 1024);
|
|
178
|
+
const webkitMB = _getWebKitMemoryMB();
|
|
179
|
+
if (webkitMB <= 0) return;
|
|
180
|
+
|
|
181
|
+
// Warning threshold — log only (once per 5 min)
|
|
182
|
+
if (webkitMB > WEBKIT_WARNING_THRESHOLD_MB && webkitMB <= WEBKIT_MEMORY_LIMIT_MB) {
|
|
183
|
+
if (Date.now() - _lastMemoryWarningTime > 300000) {
|
|
184
|
+
console.error(`[Safari MCP] ⚠️ WebKit memory warning: ${Math.round(webkitMB)}MB (threshold: ${WEBKIT_WARNING_THRESHOLD_MB}MB, limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — ${_openedTabs.size} MCP tabs open`);
|
|
185
|
+
_lastMemoryWarningTime = Date.now();
|
|
139
186
|
}
|
|
140
|
-
}
|
|
141
|
-
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Action threshold — close oldest tabs (up to 2) to recover memory
|
|
142
190
|
if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
|
|
143
|
-
console.error(`[Safari MCP]
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const info = _openedTabs.get(oldestIdx);
|
|
152
|
-
if (info?.url) {
|
|
153
|
-
const tabs = await safari.listTabs();
|
|
154
|
-
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
155
|
-
const match = parsed.find(t => t.url === info.url);
|
|
156
|
-
if (match) {
|
|
157
|
-
safari.setActiveTabIndex(match.index);
|
|
158
|
-
await safari.closeTab();
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
} catch {}
|
|
162
|
-
_untrackTab(oldestIdx);
|
|
191
|
+
console.error(`[Safari MCP] 🔴 WebKit over limit: ${Math.round(webkitMB)}MB — closing oldest MCP tabs`);
|
|
192
|
+
await _closeOldestMCPTab();
|
|
193
|
+
// If still over limit and have more tabs, close another
|
|
194
|
+
if (_openedTabs.size > 1) {
|
|
195
|
+
const afterMB = _getWebKitMemoryMB();
|
|
196
|
+
if (afterMB > WEBKIT_MEMORY_LIMIT_MB) {
|
|
197
|
+
await _closeOldestMCPTab();
|
|
198
|
+
}
|
|
163
199
|
}
|
|
164
200
|
}
|
|
165
201
|
} catch {}
|
|
166
|
-
},
|
|
202
|
+
}, checkInterval);
|
|
167
203
|
_memoryCheckTimer.unref();
|
|
168
204
|
}
|
|
169
205
|
|
|
@@ -1116,21 +1152,29 @@ server.tool(
|
|
|
1116
1152
|
}
|
|
1117
1153
|
}
|
|
1118
1154
|
|
|
1119
|
-
const
|
|
1155
|
+
const rawResult = await extensionOrFallback(
|
|
1120
1156
|
"new_tab", { url },
|
|
1121
1157
|
() => safari.newTab(url)
|
|
1122
1158
|
);
|
|
1159
|
+
// AppleScript fallback returns a JSON string; extension returns an object — normalize
|
|
1160
|
+
let result = rawResult;
|
|
1161
|
+
if (typeof rawResult === 'string') {
|
|
1162
|
+
try { result = JSON.parse(rawResult); } catch {}
|
|
1163
|
+
}
|
|
1123
1164
|
// Sync safari.js tracking when extension handled new_tab
|
|
1124
1165
|
if (result?.tabIndex) {
|
|
1125
1166
|
safari.setActiveTabIndex(result.tabIndex);
|
|
1126
1167
|
_trackTab(result.tabIndex, url);
|
|
1127
1168
|
}
|
|
1128
|
-
if (result?.url) {
|
|
1129
|
-
|
|
1169
|
+
if (result?.url || url) {
|
|
1170
|
+
// Prefer requested URL over about:blank for tracking (page hasn't loaded yet)
|
|
1171
|
+
const trackUrl = (!result?.url || result.url === 'about:blank') && url ? url : result.url;
|
|
1172
|
+
safari.setActiveTabURL(trackUrl);
|
|
1130
1173
|
// Also register actual URL (may differ from requested due to redirects)
|
|
1131
|
-
_addOwnedURL(
|
|
1174
|
+
_addOwnedURL(trackUrl);
|
|
1175
|
+
if (url && url !== trackUrl) _addOwnedURL(url); // also own the requested URL (handles www redirects)
|
|
1132
1176
|
}
|
|
1133
|
-
return { content: [{ type: "text", text: typeof
|
|
1177
|
+
return { content: [{ type: "text", text: typeof rawResult === 'string' ? rawResult : JSON.stringify(result) }] };
|
|
1134
1178
|
}
|
|
1135
1179
|
);
|
|
1136
1180
|
|
|
@@ -1222,12 +1266,17 @@ server.tool(
|
|
|
1222
1266
|
// Find the new tab(s) — could be about:blank initially (OAuth popups)
|
|
1223
1267
|
for (const tab of nowTabs) {
|
|
1224
1268
|
if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
|
|
1225
|
-
// Wait
|
|
1269
|
+
// Wait for about:blank to resolve to actual URL — dynamic polling instead of fixed delay
|
|
1226
1270
|
if (tab.url === 'about:blank') {
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1271
|
+
let resolved = null;
|
|
1272
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
1273
|
+
await new Promise(r => setTimeout(r, 300)); // 300ms intervals, max 3s total
|
|
1274
|
+
const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1275
|
+
const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
|
|
1276
|
+
resolved = refreshedTabs.find(t => t.index === tab.index);
|
|
1277
|
+
if (resolved && resolved.url !== 'about:blank') break;
|
|
1278
|
+
resolved = null;
|
|
1279
|
+
}
|
|
1231
1280
|
if (resolved && resolved.url !== 'about:blank') {
|
|
1232
1281
|
if (urlContains && !resolved.url.includes(urlContains)) continue;
|
|
1233
1282
|
await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
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",
|
|
@@ -28,11 +28,20 @@
|
|
|
28
28
|
"web-automation",
|
|
29
29
|
"anthropic",
|
|
30
30
|
"windsurf",
|
|
31
|
-
"continue-dev"
|
|
31
|
+
"continue-dev",
|
|
32
|
+
"devtools",
|
|
33
|
+
"testing",
|
|
34
|
+
"scraping",
|
|
35
|
+
"screenshot",
|
|
36
|
+
"ai-automation"
|
|
32
37
|
],
|
|
33
38
|
"author": "Achiya Automation <info@achiya-automation.com>",
|
|
34
39
|
"license": "MIT",
|
|
35
|
-
"homepage": "https://
|
|
40
|
+
"homepage": "https://achiya-automation.com",
|
|
41
|
+
"funding": {
|
|
42
|
+
"type": "github",
|
|
43
|
+
"url": "https://github.com/sponsors/achiya-automation"
|
|
44
|
+
},
|
|
36
45
|
"repository": {
|
|
37
46
|
"type": "git",
|
|
38
47
|
"url": "https://github.com/achiya-automation/safari-mcp.git"
|
|
@@ -53,7 +62,6 @@
|
|
|
53
62
|
"safari-helper",
|
|
54
63
|
"safari-helper.swift",
|
|
55
64
|
"extension/",
|
|
56
|
-
"xcode/",
|
|
57
65
|
"glama.json",
|
|
58
66
|
"LICENSE",
|
|
59
67
|
"README.md"
|