safari-mcp 2.2.0 → 2.3.1
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 +60 -1
- package/extension/background.js +20 -10
- package/index.js +42 -15
- package/mcp-helpers.js +1 -1
- package/package.json +1 -1
- package/safari.js +98 -41
package/README.md
CHANGED
|
@@ -69,7 +69,12 @@
|
|
|
69
69
|
npm install -g safari-mcp
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
**Option B —
|
|
72
|
+
**Option B — Homebrew:**
|
|
73
|
+
```bash
|
|
74
|
+
brew install achiya-automation/tap/safari-mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Option C — from source:**
|
|
73
78
|
```bash
|
|
74
79
|
git clone https://github.com/achiya-automation/safari-mcp.git
|
|
75
80
|
cd safari-mcp
|
|
@@ -142,6 +147,29 @@ Add to your MCP client config:
|
|
|
142
147
|
|
|
143
148
|
---
|
|
144
149
|
|
|
150
|
+
## Usage Workflow
|
|
151
|
+
|
|
152
|
+
The recommended pattern for AI agents using Safari MCP:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
1. safari_snapshot → Get page state (accessibility tree)
|
|
156
|
+
2. safari_click/fill/... → Interact with elements by ref
|
|
157
|
+
3. safari_snapshot → Verify the result
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Element targeting** — tools accept multiple targeting strategies:
|
|
161
|
+
|
|
162
|
+
| Strategy | Example | Best for |
|
|
163
|
+
|----------|---------|----------|
|
|
164
|
+
| CSS selector | `#login-btn`, `.submit` | Unique elements |
|
|
165
|
+
| Visible text | `"Sign In"`, `"Submit"` | Buttons, links |
|
|
166
|
+
| Coordinates | `x: 100, y: 200` | Canvas, custom widgets |
|
|
167
|
+
| Ref from snapshot | `ref: "e42"` | Any element from accessibility tree |
|
|
168
|
+
|
|
169
|
+
> **Tip:** Start with `safari_snapshot` to get element refs, then use refs for precise targeting. This is faster and more reliable than CSS selectors.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
145
173
|
## Tools (80)
|
|
146
174
|
|
|
147
175
|
### Navigation (4)
|
|
@@ -317,6 +345,20 @@ Add to your MCP client config:
|
|
|
317
345
|
|
|
318
346
|
---
|
|
319
347
|
|
|
348
|
+
## Security
|
|
349
|
+
|
|
350
|
+
Safari MCP runs locally on your Mac with minimal attack surface:
|
|
351
|
+
|
|
352
|
+
| Aspect | Detail |
|
|
353
|
+
|--------|--------|
|
|
354
|
+
| Network | **No remote connections** — all communication is local (stdio + localhost) |
|
|
355
|
+
| Permissions | macOS system permissions required (Screen Recording for screenshots) |
|
|
356
|
+
| Data | No telemetry, no analytics, no data sent anywhere |
|
|
357
|
+
| Extension | Communicates only with `localhost:9224`, validated by Safari |
|
|
358
|
+
| Code | Fully open source (MIT) — audit every line |
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
320
362
|
## Safari MCP vs Alternatives
|
|
321
363
|
|
|
322
364
|
| Feature | Safari MCP | Chrome DevTools MCP | Playwright MCP |
|
|
@@ -335,6 +377,23 @@ Add to your MCP client config:
|
|
|
335
377
|
|
|
336
378
|
> **Tip:** Use Safari MCP for daily browsing tasks (95% of work) and Chrome DevTools MCP only for Lighthouse/Performance audits.
|
|
337
379
|
|
|
380
|
+
### Safari MCP Servers Comparison
|
|
381
|
+
|
|
382
|
+
| Feature | **safari-mcp** | MCPSafari | safari-mcp-server |
|
|
383
|
+
|---------|:--------------:|:---------:|:-----------------:|
|
|
384
|
+
| Tools | **80** | 23 | ~10 |
|
|
385
|
+
| License | **MIT** | None | MIT |
|
|
386
|
+
| Install | npm / Homebrew | Binary | npm |
|
|
387
|
+
| Storage (cookies, localStorage) | **10 tools** | None | None |
|
|
388
|
+
| Data extraction (tables, links) | **5 tools** | None | None |
|
|
389
|
+
| Network mocking | **Yes** | No | No |
|
|
390
|
+
| Device emulation | **Yes** | No | No |
|
|
391
|
+
| File upload (no dialog) | **Yes** | No | No |
|
|
392
|
+
| PDF export | **Yes** | No | No |
|
|
393
|
+
| Console capture | **4 tools** | 1 | No |
|
|
394
|
+
| Performance metrics | **Yes** | No | No |
|
|
395
|
+
| Fallback engine | **Dual (Extension + AppleScript)** | Extension only | WebDriver |
|
|
396
|
+
|
|
338
397
|
---
|
|
339
398
|
|
|
340
399
|
## Architecture
|
package/extension/background.js
CHANGED
|
@@ -1647,16 +1647,20 @@ async function _verifyProfileMatch(expectedProfile) {
|
|
|
1647
1647
|
await new Promise(r => setTimeout(r, 500));
|
|
1648
1648
|
|
|
1649
1649
|
// Ask the server to verify which profile has this nonce
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1650
|
+
let verifyRes;
|
|
1651
|
+
try {
|
|
1652
|
+
verifyRes = await fetch(`${HTTP_URL}/verify-profile`, {
|
|
1653
|
+
method: "POST",
|
|
1654
|
+
headers: { "Content-Type": "application/json" },
|
|
1655
|
+
body: JSON.stringify({ nonce, expectedProfile }),
|
|
1656
|
+
signal: AbortSignal.timeout(5000),
|
|
1657
|
+
});
|
|
1658
|
+
} finally {
|
|
1659
|
+
// Always clean up test tab — even if fetch fails
|
|
1660
|
+
await browser.tabs.remove(checkTab.id).catch(() => {});
|
|
1661
|
+
}
|
|
1658
1662
|
|
|
1659
|
-
if (verifyRes.ok) {
|
|
1663
|
+
if (verifyRes && verifyRes.ok) {
|
|
1660
1664
|
const result = await verifyRes.json();
|
|
1661
1665
|
if (result.match) {
|
|
1662
1666
|
await browser.storage.local.set({ mcpVerifiedProfile: expectedProfile });
|
|
@@ -1825,7 +1829,13 @@ async function execInAllFrames(func, args = [], tabId = null) {
|
|
|
1825
1829
|
}
|
|
1826
1830
|
}
|
|
1827
1831
|
|
|
1828
|
-
function waitForTabLoad(tabId, timeout = 30000) {
|
|
1832
|
+
async function waitForTabLoad(tabId, timeout = 30000) {
|
|
1833
|
+
// Check if already complete BEFORE registering listeners (prevents missing instant-complete events)
|
|
1834
|
+
try {
|
|
1835
|
+
const tab = await browser.tabs.get(tabId);
|
|
1836
|
+
if (tab.status === "complete") return;
|
|
1837
|
+
} catch { return; } // Tab already gone
|
|
1838
|
+
|
|
1829
1839
|
return new Promise((resolve) => {
|
|
1830
1840
|
function cleanup() {
|
|
1831
1841
|
clearTimeout(timer);
|
package/index.js
CHANGED
|
@@ -13,6 +13,9 @@ import { WebSocketServer } from "ws";
|
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
16
19
|
|
|
17
20
|
// ========== SINGLETON: kill stale instances from previous sessions ==========
|
|
18
21
|
// NOTE: Claude Code VSCode may start 2 instances simultaneously (~40ms apart).
|
|
@@ -66,11 +69,18 @@ function _untrackTab(tabIndex) {
|
|
|
66
69
|
async function _cleanupTabs() {
|
|
67
70
|
if (_openedTabs.size === 0) return;
|
|
68
71
|
console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// Close by URL (not index) — indices shift as tabs are closed
|
|
73
|
+
const urlsToClose = [..._openedTabs.values()].map(v => v.url).filter(Boolean);
|
|
74
|
+
for (const url of urlsToClose) {
|
|
71
75
|
try {
|
|
72
|
-
|
|
73
|
-
await safari.
|
|
76
|
+
// Re-resolve index by URL before each close (indices shift after each closure)
|
|
77
|
+
const tabs = await safari.listTabs();
|
|
78
|
+
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
79
|
+
const match = parsed.find(t => t.url === url);
|
|
80
|
+
if (match) {
|
|
81
|
+
safari.setActiveTabIndex(match.index);
|
|
82
|
+
await safari.closeTab();
|
|
83
|
+
}
|
|
74
84
|
} catch {}
|
|
75
85
|
}
|
|
76
86
|
_openedTabs.clear();
|
|
@@ -84,7 +94,7 @@ function _startMemoryMonitor() {
|
|
|
84
94
|
// Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
|
|
85
95
|
let psOut = "0";
|
|
86
96
|
try {
|
|
87
|
-
const pids = execFileSync("pgrep", ["-
|
|
97
|
+
const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
|
|
88
98
|
if (pids) {
|
|
89
99
|
const pidList = pids.split("\n").join(",");
|
|
90
100
|
psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
|
|
@@ -101,8 +111,17 @@ function _startMemoryMonitor() {
|
|
|
101
111
|
}
|
|
102
112
|
if (oldestIdx !== null) {
|
|
103
113
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
// Re-resolve by URL before closing (indices shift as tabs change)
|
|
115
|
+
const info = _openedTabs.get(oldestIdx);
|
|
116
|
+
if (info?.url) {
|
|
117
|
+
const tabs = await safari.listTabs();
|
|
118
|
+
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
119
|
+
const match = parsed.find(t => t.url === info.url);
|
|
120
|
+
if (match) {
|
|
121
|
+
safari.setActiveTabIndex(match.index);
|
|
122
|
+
await safari.closeTab();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
106
125
|
} catch {}
|
|
107
126
|
_untrackTab(oldestIdx);
|
|
108
127
|
}
|
|
@@ -238,9 +257,10 @@ try {
|
|
|
238
257
|
}
|
|
239
258
|
|
|
240
259
|
// POST /result — extension sends command result
|
|
260
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap — prevents DoS from large payloads
|
|
241
261
|
if (req.method === "POST" && req.url === "/result") {
|
|
242
262
|
let body = "";
|
|
243
|
-
req.on("data", (chunk) =>
|
|
263
|
+
req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
244
264
|
req.on("end", () => {
|
|
245
265
|
try {
|
|
246
266
|
const msg = JSON.parse(body);
|
|
@@ -281,13 +301,13 @@ try {
|
|
|
281
301
|
// POST /verify-profile — extension asks server to check which profile has a nonce tab
|
|
282
302
|
if (req.method === "POST" && req.url === "/verify-profile") {
|
|
283
303
|
let body = "";
|
|
284
|
-
req.on("data", (chunk) =>
|
|
304
|
+
req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
285
305
|
req.on("end", async () => {
|
|
286
306
|
try {
|
|
287
307
|
const { nonce, expectedProfile } = JSON.parse(body);
|
|
288
308
|
// Use AppleScript to find which window contains the nonce in a tab title
|
|
289
|
-
const safeNonce = nonce.replace(/
|
|
290
|
-
const safeProfile = (expectedProfile || "").replace(/
|
|
309
|
+
const safeNonce = String(nonce).replace(/[^0-9]/g, ''); // nonce is numeric only
|
|
310
|
+
const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, ''); // whitelist: letters, numbers, spaces, hyphens, underscores
|
|
291
311
|
// Check via AppleScript — look for the nonce in the profile window
|
|
292
312
|
const { execFile } = await import("node:child_process");
|
|
293
313
|
const { promisify } = await import("node:util");
|
|
@@ -333,7 +353,7 @@ try {
|
|
|
333
353
|
// POST /proxy-command — secondary instances send commands through primary
|
|
334
354
|
if (req.method === "POST" && req.url === "/proxy-command") {
|
|
335
355
|
let body = "";
|
|
336
|
-
req.on("data", (chunk) =>
|
|
356
|
+
req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
|
|
337
357
|
req.on("end", async () => {
|
|
338
358
|
try {
|
|
339
359
|
const { type, payload } = JSON.parse(body);
|
|
@@ -381,6 +401,7 @@ setTimeout(() => {
|
|
|
381
401
|
}, 2000);
|
|
382
402
|
|
|
383
403
|
async function _checkPrimaryExtension() {
|
|
404
|
+
if (_isExtensionHost) return; // Already hosting — stop polling
|
|
384
405
|
try {
|
|
385
406
|
const res = await fetch(`http://127.0.0.1:${HTTP_PORT}/proxy-check`, {
|
|
386
407
|
method: "GET",
|
|
@@ -494,7 +515,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
|
|
|
494
515
|
// Per-command timeouts — fast commands get short timeouts, nav/screenshot get longer ones
|
|
495
516
|
const _commandTimeouts = {
|
|
496
517
|
click: 10000, fill: 5000, read_page: 30000, get_source: 10000, evaluate: 30000,
|
|
497
|
-
type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element:
|
|
518
|
+
type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element: 15000,
|
|
498
519
|
hover: 5000, list_tabs: 5000, new_tab: 15000, close_tab: 5000, switch_tab: 30000,
|
|
499
520
|
wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
|
|
500
521
|
reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
|
|
@@ -547,9 +568,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
547
568
|
return result;
|
|
548
569
|
}
|
|
549
570
|
|
|
571
|
+
// Read version from package.json to avoid hardcoded mismatch
|
|
572
|
+
const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
|
|
550
573
|
const server = new McpServer({
|
|
551
574
|
name: "safari-mcp",
|
|
552
|
-
version:
|
|
575
|
+
version: _pkgVersion,
|
|
553
576
|
description: "Safari browser automation - lightweight, keeps logins",
|
|
554
577
|
});
|
|
555
578
|
|
|
@@ -639,7 +662,7 @@ server.tool(
|
|
|
639
662
|
const gen = safari.getNextSnapshotGen();
|
|
640
663
|
const result = await extensionOrFallback(
|
|
641
664
|
"snapshot", { selector: args.selector, gen },
|
|
642
|
-
() => safari.takeSnapshot(args)
|
|
665
|
+
() => safari.takeSnapshot({ ...args, _gen: gen })
|
|
643
666
|
);
|
|
644
667
|
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
645
668
|
}
|
|
@@ -1114,12 +1137,16 @@ server.tool(
|
|
|
1114
1137
|
if (resolved && resolved.url !== 'about:blank') {
|
|
1115
1138
|
if (urlContains && !resolved.url.includes(urlContains)) continue;
|
|
1116
1139
|
await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
|
|
1140
|
+
safari.setActiveTabIndex(resolved.index);
|
|
1141
|
+
safari.setActiveTabURL(resolved.url);
|
|
1117
1142
|
return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
|
|
1118
1143
|
}
|
|
1119
1144
|
continue;
|
|
1120
1145
|
}
|
|
1121
1146
|
if (urlContains && !tab.url.includes(urlContains)) continue;
|
|
1122
1147
|
await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
|
|
1148
|
+
safari.setActiveTabIndex(tab.index);
|
|
1149
|
+
safari.setActiveTabURL(tab.url);
|
|
1123
1150
|
return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
|
|
1124
1151
|
}
|
|
1125
1152
|
}
|
package/mcp-helpers.js
CHANGED
|
@@ -67,7 +67,7 @@ if (window.__mcpVersion !== 5) {
|
|
|
67
67
|
window.mcpIsVisible = function(el) {
|
|
68
68
|
if (!el || el.nodeType !== 1 || !el.isConnected) return false;
|
|
69
69
|
var cs = window.getComputedStyle(el);
|
|
70
|
-
if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || cs.
|
|
70
|
+
if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || parseFloat(cs.opacity) === 0) return false;
|
|
71
71
|
var r = el.getBoundingClientRect();
|
|
72
72
|
return r.width > 0 && r.height > 0;
|
|
73
73
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { execFile, spawn } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
|
-
import { join, dirname } from "node:path";
|
|
9
|
+
import { join, dirname, resolve as resolvePath } from "node:path";
|
|
10
10
|
import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
@@ -19,6 +19,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
19
19
|
// Persistent process — no subprocess spawn overhead (~5ms vs ~90ms)
|
|
20
20
|
let _helperProc = null;
|
|
21
21
|
const _helperQueue = []; // callbacks waiting for responses
|
|
22
|
+
let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — only kill after 3
|
|
22
23
|
|
|
23
24
|
// Reject all pending callbacks when helper crashes
|
|
24
25
|
function _drainHelperQueue(reason) {
|
|
@@ -83,11 +84,9 @@ function cleanupHelper() {
|
|
|
83
84
|
_helperProc = null;
|
|
84
85
|
}
|
|
85
86
|
}
|
|
87
|
+
// Signal handlers (SIGINT/SIGTERM/SIGHUP) are registered in index.js only.
|
|
88
|
+
// cleanupHelper runs via process.on("exit"), which fires when index.js calls process.exit().
|
|
86
89
|
process.on("exit", cleanupHelper);
|
|
87
|
-
process.on("SIGINT", () => { cleanupHelper(); process.exit(0); });
|
|
88
|
-
process.on("SIGTERM", () => { cleanupHelper(); process.exit(0); });
|
|
89
|
-
// SIGHUP = terminal closed, uncaught = crash — still clean up
|
|
90
|
-
process.on("SIGHUP", () => { cleanupHelper(); process.exit(0); });
|
|
91
90
|
process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
|
|
92
91
|
|
|
93
92
|
// ========== ACTIVE TAB TRACKING ==========
|
|
@@ -336,9 +335,15 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
336
335
|
resolved = true;
|
|
337
336
|
const idx = _helperQueue.indexOf(cb);
|
|
338
337
|
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
338
|
+
_helperConsecutiveTimeouts++;
|
|
339
|
+
// Only kill the daemon after 3 consecutive timeouts (single slow script shouldn't kill concurrent ops)
|
|
340
|
+
if (_helperConsecutiveTimeouts >= 3) {
|
|
341
|
+
console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts — killing daemon`);
|
|
342
|
+
_helperProc?.kill();
|
|
343
|
+
_helperProc = null;
|
|
344
|
+
_helperConsecutiveTimeouts = 0;
|
|
345
|
+
setTimeout(startHelper, 100);
|
|
346
|
+
}
|
|
342
347
|
reject(new Error("safari-helper timeout"));
|
|
343
348
|
}, timeout);
|
|
344
349
|
|
|
@@ -346,6 +351,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
346
351
|
if (resolved) return;
|
|
347
352
|
resolved = true;
|
|
348
353
|
clearTimeout(timer);
|
|
354
|
+
_helperConsecutiveTimeouts = 0; // Reset on success
|
|
349
355
|
try {
|
|
350
356
|
const parsed = JSON.parse(line);
|
|
351
357
|
if (parsed.error) reject(new Error(parsed.error));
|
|
@@ -355,14 +361,21 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
355
361
|
}
|
|
356
362
|
}
|
|
357
363
|
|
|
358
|
-
|
|
359
|
-
if (!_helperProc || !_helperProc.stdin) {
|
|
360
|
-
|
|
361
|
-
_helperQueue.pop(); // Remove the callback we just pushed
|
|
364
|
+
// Check process availability BEFORE pushing to queue (prevents dangling callbacks)
|
|
365
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
366
|
+
clearTimeout(timer);
|
|
362
367
|
reject(new Error("safari-helper not available"));
|
|
363
368
|
return;
|
|
364
369
|
}
|
|
365
|
-
|
|
370
|
+
_helperQueue.push(cb);
|
|
371
|
+
try {
|
|
372
|
+
_helperProc.stdin.write(JSON.stringify({ script }) + "\n");
|
|
373
|
+
} catch (writeErr) {
|
|
374
|
+
const idx = _helperQueue.indexOf(cb);
|
|
375
|
+
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
reject(new Error("safari-helper write failed: " + writeErr.message));
|
|
378
|
+
}
|
|
366
379
|
});
|
|
367
380
|
}
|
|
368
381
|
|
|
@@ -623,17 +636,21 @@ export async function navigate(url) {
|
|
|
623
636
|
|
|
624
637
|
export async function goBack() {
|
|
625
638
|
// Navigate back + smart wait: check immediately, then poll only if loading
|
|
626
|
-
|
|
639
|
+
const result = await runJS(
|
|
627
640
|
`(async function(){history.back();await new Promise(function(r){setTimeout(r,50)});if(document.readyState!=='complete'){for(var i=0;i<30;i++){await new Promise(function(r){setTimeout(r,150)});if(document.readyState==='complete')break;}}return JSON.stringify({title:document.title,url:location.href});})()`,
|
|
628
641
|
{ timeout: 10000 }
|
|
629
642
|
);
|
|
643
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
644
|
+
return result;
|
|
630
645
|
}
|
|
631
646
|
|
|
632
647
|
export async function goForward() {
|
|
633
|
-
|
|
648
|
+
const result = await runJS(
|
|
634
649
|
`(async function(){history.forward();await new Promise(function(r){setTimeout(r,50)});if(document.readyState!=='complete'){for(var i=0;i<30;i++){await new Promise(function(r){setTimeout(r,150)});if(document.readyState==='complete')break;}}return JSON.stringify({title:document.title,url:location.href});})()`,
|
|
635
650
|
{ timeout: 10000 }
|
|
636
651
|
);
|
|
652
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
653
|
+
return result;
|
|
637
654
|
}
|
|
638
655
|
|
|
639
656
|
export async function reload(hardReload = false) {
|
|
@@ -641,10 +658,12 @@ export async function reload(hardReload = false) {
|
|
|
641
658
|
await runJS(hardReload ? "location.reload(true)" : "location.reload()");
|
|
642
659
|
await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
|
|
643
660
|
// Poll readyState in a single call
|
|
644
|
-
|
|
661
|
+
const result = await runJS(
|
|
645
662
|
`(async function(){for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}return JSON.stringify({title:document.title,url:location.href});})()`,
|
|
646
663
|
{ timeout: 10000 }
|
|
647
664
|
);
|
|
665
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
666
|
+
return result;
|
|
648
667
|
}
|
|
649
668
|
|
|
650
669
|
// ========== PAGE INFO ==========
|
|
@@ -1239,7 +1258,7 @@ export async function fill({ selector, value, ref }) {
|
|
|
1239
1258
|
|
|
1240
1259
|
const fillResult = await runJS(
|
|
1241
1260
|
`(function(){` +
|
|
1242
|
-
`var el=document.querySelector('${selector.replace(/'/g, "\\'")}');` +
|
|
1261
|
+
`var el=document.querySelector('${selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}');` +
|
|
1243
1262
|
`if(!el)return 'Element not found';` +
|
|
1244
1263
|
`el.focus();el.click();` +
|
|
1245
1264
|
`var sel=window.getSelection();if(sel.rangeCount){var r=document.createRange();r.selectNodeContents(el);r.collapse(false);sel.removeAllRanges();sel.addRange(r);}` +
|
|
@@ -1624,15 +1643,19 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1624
1643
|
await osascript(
|
|
1625
1644
|
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${Math.min(Number(h) + 100, 5000)}}`
|
|
1626
1645
|
);
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1646
|
+
try {
|
|
1647
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1648
|
+
// Use do shell script to inherit osascript's Screen Recording permission
|
|
1649
|
+
await osascript(
|
|
1650
|
+
`do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
1651
|
+
{ timeout: 15000 }
|
|
1652
|
+
);
|
|
1653
|
+
} finally {
|
|
1654
|
+
// Always restore bounds — even if screencapture fails
|
|
1655
|
+
await osascript(
|
|
1656
|
+
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
|
|
1657
|
+
).catch(() => {});
|
|
1658
|
+
}
|
|
1636
1659
|
} else {
|
|
1637
1660
|
// Try direct execFile first (works if VS Code has Screen Recording permission)
|
|
1638
1661
|
try {
|
|
@@ -1867,8 +1890,9 @@ export async function newTab(url = "") {
|
|
|
1867
1890
|
else { await osascript('tell application "Safari" to make new document'); }
|
|
1868
1891
|
}
|
|
1869
1892
|
}
|
|
1893
|
+
// Get count atomically from the same tell block as tab creation (avoids TOCTOU if user opens tabs concurrently)
|
|
1870
1894
|
const tabCount = await osascriptFast(`tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`);
|
|
1871
|
-
_activeTabIndex = Number(tabCount);
|
|
1895
|
+
_activeTabIndex = Number(tabCount); // New tab is always appended as last
|
|
1872
1896
|
_activeTabURL = url || null;
|
|
1873
1897
|
_lastResolveTime = Date.now();
|
|
1874
1898
|
// Wait for page load if URL given
|
|
@@ -2058,11 +2082,11 @@ export async function handleDialog({ action = "accept", text }) {
|
|
|
2058
2082
|
}
|
|
2059
2083
|
if (action === "accept") {
|
|
2060
2084
|
await runJS(
|
|
2061
|
-
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.alert;window.alert=function(){window.alert=window.__origAlert;};"
|
|
2085
|
+
"window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.__origAlert||window.alert;window.alert=function(){window.alert=window.__origAlert;};"
|
|
2062
2086
|
);
|
|
2063
2087
|
} else {
|
|
2064
2088
|
await runJS(
|
|
2065
|
-
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
|
|
2089
|
+
"window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
|
|
2066
2090
|
);
|
|
2067
2091
|
}
|
|
2068
2092
|
return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
|
|
@@ -2106,19 +2130,23 @@ export async function getNetworkRequests({ limit = 50 } = {}) {
|
|
|
2106
2130
|
|
|
2107
2131
|
export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
|
|
2108
2132
|
if (sourceSelector && targetSelector) {
|
|
2109
|
-
const srcSel = sourceSelector.replace(/'/g, "\\'");
|
|
2110
|
-
const tgtSel = targetSelector.replace(/'/g, "\\'");
|
|
2133
|
+
const srcSel = sourceSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2134
|
+
const tgtSel = targetSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2111
2135
|
return runJS(
|
|
2112
2136
|
`(function(){` +
|
|
2113
2137
|
`var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
|
|
2114
2138
|
`if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
|
|
2115
2139
|
`var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
|
|
2116
2140
|
`var sx=sr.x+sr.width/2,sy=sr.y+sr.height/2,tx=tr.x+tr.width/2,ty=tr.y+tr.height/2;` +
|
|
2141
|
+
`var dt=new DataTransfer();` +
|
|
2142
|
+
`src.dispatchEvent(new DragEvent('dragstart',{clientX:sx,clientY:sy,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2117
2143
|
`src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
2118
2144
|
`src.dispatchEvent(new MouseEvent('mousemove',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
2145
|
+
`tgt.dispatchEvent(new DragEvent('dragover',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2119
2146
|
`tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
2120
2147
|
`tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
2121
|
-
`tgt.dispatchEvent(new DragEvent('drop',{bubbles:true}));` +
|
|
2148
|
+
`tgt.dispatchEvent(new DragEvent('drop',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2149
|
+
`src.dispatchEvent(new DragEvent('dragend',{bubbles:true}));` +
|
|
2122
2150
|
`return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
|
|
2123
2151
|
);
|
|
2124
2152
|
}
|
|
@@ -2137,9 +2165,26 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
|
|
|
2137
2165
|
throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
|
|
2138
2166
|
}
|
|
2139
2167
|
|
|
2168
|
+
// ========== FILE PATH SAFETY ==========
|
|
2169
|
+
// Prevent reading sensitive system files via upload/paste tools
|
|
2170
|
+
function _validateFilePath(filePath) {
|
|
2171
|
+
const resolved = resolvePath(filePath);
|
|
2172
|
+
if (resolved.includes('..')) throw new Error("Path traversal not allowed: " + filePath);
|
|
2173
|
+
const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
|
|
2174
|
+
const lower = resolved.toLowerCase();
|
|
2175
|
+
for (const b of blocked) {
|
|
2176
|
+
if (lower.includes(b)) throw new Error("Blocked: reading sensitive path " + filePath);
|
|
2177
|
+
}
|
|
2178
|
+
// Must be under /Users/ or /tmp/ or /var/folders/ (macOS temp)
|
|
2179
|
+
if (!resolved.startsWith('/Users/') && !resolved.startsWith('/tmp/') && !resolved.startsWith('/var/folders/') && !resolved.startsWith('/private/tmp/')) {
|
|
2180
|
+
throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2140
2184
|
// ========== UPLOAD FILE ==========
|
|
2141
2185
|
|
|
2142
2186
|
export async function uploadFile({ selector, filePath }) {
|
|
2187
|
+
_validateFilePath(filePath);
|
|
2143
2188
|
// Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
|
|
2144
2189
|
// NO file dialog, NO System Events, NO focus stealing
|
|
2145
2190
|
|
|
@@ -2150,10 +2195,10 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2150
2195
|
repeat with w in every window
|
|
2151
2196
|
if exists sheet 1 of w then
|
|
2152
2197
|
try
|
|
2153
|
-
click button "
|
|
2198
|
+
click button "Cancel" of sheet 1 of w
|
|
2154
2199
|
on error
|
|
2155
2200
|
try
|
|
2156
|
-
click button "
|
|
2201
|
+
click button "ביטול" of sheet 1 of w
|
|
2157
2202
|
on error
|
|
2158
2203
|
key code 53
|
|
2159
2204
|
end try
|
|
@@ -2257,6 +2302,7 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2257
2302
|
// ========== PASTE IMAGE FROM FILE ==========
|
|
2258
2303
|
|
|
2259
2304
|
export async function pasteImageFromFile({ filePath }) {
|
|
2305
|
+
_validateFilePath(filePath);
|
|
2260
2306
|
// Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
|
|
2261
2307
|
const { extname } = await import("node:path");
|
|
2262
2308
|
const ext = extname(filePath).toLowerCase().replace(".", "");
|
|
@@ -2357,9 +2403,9 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
|
|
|
2357
2403
|
|
|
2358
2404
|
export async function resetEmulation() {
|
|
2359
2405
|
await refreshTargetWindow();
|
|
2360
|
-
// Reset user agent
|
|
2406
|
+
// Reset user agent — remove the defineProperty override set by emulate()
|
|
2361
2407
|
await runJS(
|
|
2362
|
-
"
|
|
2408
|
+
"try{var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent');if(d){Object.defineProperty(navigator,'userAgent',d);}else{delete navigator.userAgent;}}catch(_){}"
|
|
2363
2409
|
);
|
|
2364
2410
|
// Maximize window
|
|
2365
2411
|
await osascript(
|
|
@@ -2445,10 +2491,14 @@ export async function savePDF({ path: pdfPath }) {
|
|
|
2445
2491
|
// instead of guessing CSS selectors. Much faster, no hallucination risk.
|
|
2446
2492
|
|
|
2447
2493
|
let _snapshotGen = 0;
|
|
2494
|
+
// getNextSnapshotGen is used by the MCP tool path (index.js) to reserve a gen for the extension.
|
|
2495
|
+
// If the extension fails and falls back to takeSnapshot(), takeSnapshot uses _snapshotGen directly
|
|
2496
|
+
// (which was already incremented by getNextSnapshotGen) — so no double-increment occurs.
|
|
2448
2497
|
export function getNextSnapshotGen() { return _snapshotGen++; }
|
|
2449
2498
|
|
|
2450
|
-
export async function takeSnapshot({ selector } = {}) {
|
|
2451
|
-
|
|
2499
|
+
export async function takeSnapshot({ selector, _gen } = {}) {
|
|
2500
|
+
// Use provided gen (from tool path) or allocate a new one (direct call)
|
|
2501
|
+
const gen = _gen != null ? _gen : _snapshotGen++;
|
|
2452
2502
|
const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
|
|
2453
2503
|
|
|
2454
2504
|
const result = await runJS(
|
|
@@ -2606,6 +2656,7 @@ export async function takeSnapshot({ selector } = {}) {
|
|
|
2606
2656
|
}
|
|
2607
2657
|
|
|
2608
2658
|
window.__mcpRefs = {};
|
|
2659
|
+
window.__mcpRefsTime = Date.now();
|
|
2609
2660
|
var root = ${root};
|
|
2610
2661
|
if (!root) return 'Element not found';
|
|
2611
2662
|
walk(root, 0);
|
|
@@ -2817,7 +2868,12 @@ export async function importStorageState({ state }) {
|
|
|
2817
2868
|
cmds.push(`sessionStorage.setItem('${esc(k)}','${esc(v)}')`);
|
|
2818
2869
|
}
|
|
2819
2870
|
}
|
|
2820
|
-
|
|
2871
|
+
// Use runJSLarge for large sessions (many cookies/localStorage keys can exceed 260KB limit of runJS)
|
|
2872
|
+
const script = cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'";
|
|
2873
|
+
if (script.length > 200000) {
|
|
2874
|
+
return runJSLarge(script, { timeout: 30000 });
|
|
2875
|
+
}
|
|
2876
|
+
return runJS(script);
|
|
2821
2877
|
}
|
|
2822
2878
|
|
|
2823
2879
|
// ========== CLIPBOARD ==========
|
|
@@ -2939,8 +2995,9 @@ export async function clearNetworkMocks() {
|
|
|
2939
2995
|
// ========== WAIT FOR TIME ==========
|
|
2940
2996
|
|
|
2941
2997
|
export async function waitForTime({ ms }) {
|
|
2942
|
-
|
|
2943
|
-
|
|
2998
|
+
const capped = Math.min(Number(ms) || 0, 60000); // Cap at 60 seconds
|
|
2999
|
+
await new Promise((r) => setTimeout(r, capped));
|
|
3000
|
+
return capped < Number(ms) ? `Waited ${capped}ms (capped from ${ms}ms — max 60s)` : `Waited ${ms}ms`;
|
|
2944
3001
|
}
|
|
2945
3002
|
|
|
2946
3003
|
// ========== NETWORK CAPTURE (Detailed) ==========
|