safari-mcp 2.1.8 → 2.3.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/README.md +60 -1
- package/extension/background.js +30 -11
- package/index.js +59 -22
- package/mcp-helpers.js +299 -0
- package/package.json +2 -1
- package/safari.js +121 -47
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
|
@@ -957,7 +957,9 @@ async function handleCommand(type, payload) {
|
|
|
957
957
|
// Learn profile window from newly created tab
|
|
958
958
|
if (!_profileWindowId) { _profileWindowId = updated.windowId; browser.storage.local.set({ mcpProfileWindowId: _profileWindowId }).catch(() => {}); }
|
|
959
959
|
// CRITICAL: Set new tab as the target for this session's subsequent commands
|
|
960
|
-
|
|
960
|
+
// Use the requested URL (not updated.url) when page hasn't loaded yet (still about:blank)
|
|
961
|
+
const trackUrl = (updated.url && updated.url !== "about:blank") ? updated.url : (payload.url || updated.url);
|
|
962
|
+
_setSessionTab(sessionId, updated.id, trackUrl);
|
|
961
963
|
return { title: updated.title, url: updated.url, tabIndex: updated.index + 1 };
|
|
962
964
|
}
|
|
963
965
|
|
|
@@ -1049,7 +1051,7 @@ async function handleCommand(type, payload) {
|
|
|
1049
1051
|
|
|
1050
1052
|
// --- Snapshot (accessibility tree with ref IDs) ---
|
|
1051
1053
|
case "snapshot": {
|
|
1052
|
-
return await execInTab((rootSelector) => {
|
|
1054
|
+
return await execInTab((rootSelector, snapshotGen) => {
|
|
1053
1055
|
// Clean ALL stale data-mcp-ref attributes from previous snapshots.
|
|
1054
1056
|
// Without this, old refs remain on DOM and findByRef/CSS selector can target WRONG elements.
|
|
1055
1057
|
document.querySelectorAll("[data-mcp-ref]").forEach(function(el) { el.removeAttribute("data-mcp-ref"); });
|
|
@@ -1099,7 +1101,7 @@ async function handleCommand(type, payload) {
|
|
|
1099
1101
|
|
|
1100
1102
|
const interactive = isInteractive(el);
|
|
1101
1103
|
const currentId = id++;
|
|
1102
|
-
const refId = "
|
|
1104
|
+
const refId = snapshotGen + "_" + currentId;
|
|
1103
1105
|
|
|
1104
1106
|
let attrs = "";
|
|
1105
1107
|
if (interactive) {
|
|
@@ -1209,7 +1211,7 @@ async function handleCommand(type, payload) {
|
|
|
1209
1211
|
tree += "\n[WARNING: Snapshot truncated at " + MAX_ELEMENTS + " elements. Use selector parameter to focus on a specific section.]";
|
|
1210
1212
|
}
|
|
1211
1213
|
return tree;
|
|
1212
|
-
}, [payload.selector || null], tabId);
|
|
1214
|
+
}, [payload.selector || null, payload.gen != null ? payload.gen : 0], tabId);
|
|
1213
1215
|
}
|
|
1214
1216
|
|
|
1215
1217
|
// --- Double Click ---
|
|
@@ -1768,15 +1770,26 @@ async function getActiveTab() {
|
|
|
1768
1770
|
return tabs[0];
|
|
1769
1771
|
}
|
|
1770
1772
|
|
|
1773
|
+
// Track which tabs already have the deep query helpers injected
|
|
1774
|
+
const _helpersInjected = new Set();
|
|
1775
|
+
// Clean up when tabs are removed or navigated
|
|
1776
|
+
browser.tabs.onRemoved.addListener((tabId) => { _helpersInjected.delete(tabId); });
|
|
1777
|
+
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
|
1778
|
+
if (changeInfo.status === "loading") _helpersInjected.delete(tabId);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1771
1781
|
async function execInTab(func, args = [], tabId = null) {
|
|
1772
1782
|
const id = tabId || (await getActiveTab()).id;
|
|
1773
1783
|
try {
|
|
1774
|
-
// Auto-inject deep query helpers
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1784
|
+
// Auto-inject deep query helpers — skip if already injected for this tab+page
|
|
1785
|
+
if (!_helpersInjected.has(id)) {
|
|
1786
|
+
await browser.scripting.executeScript({
|
|
1787
|
+
target: { tabId: id },
|
|
1788
|
+
world: "MAIN",
|
|
1789
|
+
func: _deepQueryScript,
|
|
1790
|
+
}).catch(() => {});
|
|
1791
|
+
_helpersInjected.add(id);
|
|
1792
|
+
}
|
|
1780
1793
|
|
|
1781
1794
|
const results = await browser.scripting.executeScript({
|
|
1782
1795
|
target: { tabId: id },
|
|
@@ -1812,7 +1825,13 @@ async function execInAllFrames(func, args = [], tabId = null) {
|
|
|
1812
1825
|
}
|
|
1813
1826
|
}
|
|
1814
1827
|
|
|
1815
|
-
function waitForTabLoad(tabId, timeout = 30000) {
|
|
1828
|
+
async function waitForTabLoad(tabId, timeout = 30000) {
|
|
1829
|
+
// Check if already complete BEFORE registering listeners (prevents missing instant-complete events)
|
|
1830
|
+
try {
|
|
1831
|
+
const tab = await browser.tabs.get(tabId);
|
|
1832
|
+
if (tab.status === "complete") return;
|
|
1833
|
+
} catch { return; } // Tab already gone
|
|
1834
|
+
|
|
1816
1835
|
return new Promise((resolve) => {
|
|
1817
1836
|
function cleanup() {
|
|
1818
1837
|
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();
|
|
@@ -81,9 +91,17 @@ let _memoryCheckTimer = null;
|
|
|
81
91
|
function _startMemoryMonitor() {
|
|
82
92
|
_memoryCheckTimer = setInterval(async () => {
|
|
83
93
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
// Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
|
|
95
|
+
let psOut = "0";
|
|
96
|
+
try {
|
|
97
|
+
const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8" }).trim();
|
|
98
|
+
if (pids) {
|
|
99
|
+
const pidList = pids.split("\n").join(",");
|
|
100
|
+
psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
|
|
101
|
+
const totalKB = psOut.split("\n").reduce((sum, l) => sum + (parseInt(l.trim(), 10) || 0), 0);
|
|
102
|
+
psOut = String(totalKB / 1024);
|
|
103
|
+
}
|
|
104
|
+
} catch { psOut = "0"; }
|
|
87
105
|
const webkitMB = parseFloat(psOut) || 0;
|
|
88
106
|
if (webkitMB > WEBKIT_MEMORY_LIMIT_MB && _openedTabs.size > 1) {
|
|
89
107
|
console.error(`[Safari MCP] ⚠️ WebKit using ${Math.round(webkitMB)}MB (limit: ${WEBKIT_MEMORY_LIMIT_MB}MB) — closing oldest MCP tab`);
|
|
@@ -278,8 +296,8 @@ try {
|
|
|
278
296
|
try {
|
|
279
297
|
const { nonce, expectedProfile } = JSON.parse(body);
|
|
280
298
|
// Use AppleScript to find which window contains the nonce in a tab title
|
|
281
|
-
const safeNonce = nonce.replace(/
|
|
282
|
-
const safeProfile = (expectedProfile || "").replace(/
|
|
299
|
+
const safeNonce = String(nonce).replace(/[^0-9]/g, ''); // nonce is numeric only
|
|
300
|
+
const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, ''); // whitelist: letters, numbers, spaces, hyphens, underscores
|
|
283
301
|
// Check via AppleScript — look for the nonce in the profile window
|
|
284
302
|
const { execFile } = await import("node:child_process");
|
|
285
303
|
const { promisify } = await import("node:util");
|
|
@@ -486,12 +504,12 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
|
|
|
486
504
|
// Per-command timeouts — fast commands get short timeouts, nav/screenshot get longer ones
|
|
487
505
|
const _commandTimeouts = {
|
|
488
506
|
click: 10000, fill: 5000, read_page: 30000, get_source: 10000, evaluate: 30000,
|
|
489
|
-
type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element:
|
|
507
|
+
type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element: 15000,
|
|
490
508
|
hover: 5000, list_tabs: 5000, new_tab: 15000, close_tab: 5000, switch_tab: 30000,
|
|
491
509
|
wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
|
|
492
510
|
reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
|
|
493
511
|
double_click: 10000, right_click: 10000, clear_field: 5000, select_option: 5000, fill_form: 10000,
|
|
494
|
-
get_url: 3000, get_title: 3000,
|
|
512
|
+
replace_editor: 10000, get_url: 3000, get_title: 3000,
|
|
495
513
|
};
|
|
496
514
|
|
|
497
515
|
// Commands where null result means failure (should fall back to AppleScript)
|
|
@@ -539,9 +557,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
539
557
|
return result;
|
|
540
558
|
}
|
|
541
559
|
|
|
560
|
+
// Read version from package.json to avoid hardcoded mismatch
|
|
561
|
+
const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
|
|
542
562
|
const server = new McpServer({
|
|
543
563
|
name: "safari-mcp",
|
|
544
|
-
version:
|
|
564
|
+
version: _pkgVersion,
|
|
545
565
|
description: "Safari browser automation - lightweight, keeps logins",
|
|
546
566
|
});
|
|
547
567
|
|
|
@@ -628,9 +648,10 @@ server.tool(
|
|
|
628
648
|
"PREFERRED way to see page state. Returns accessibility tree with ref IDs for every interactive element. Use refs with click/fill/type instead of CSS selectors. Workflow: snapshot → see refs → click({ref:'0_5'}). PREFER THIS over safari_screenshot (cheaper, structured text vs heavy image) and over safari_read_page (includes interactive refs). Use safari_screenshot only when you need to see visual layout/styling.",
|
|
629
649
|
{ selector: z.string().optional().describe("CSS selector for subtree (default: full page)") },
|
|
630
650
|
async (args) => {
|
|
651
|
+
const gen = safari.getNextSnapshotGen();
|
|
631
652
|
const result = await extensionOrFallback(
|
|
632
|
-
"snapshot", { selector: args.selector },
|
|
633
|
-
() => safari.takeSnapshot(args)
|
|
653
|
+
"snapshot", { selector: args.selector, gen },
|
|
654
|
+
() => safari.takeSnapshot({ ...args, _gen: gen })
|
|
634
655
|
);
|
|
635
656
|
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
636
657
|
}
|
|
@@ -1083,20 +1104,36 @@ server.tool(
|
|
|
1083
1104
|
// Get current tab list
|
|
1084
1105
|
const beforeRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1085
1106
|
const beforeTabs = typeof beforeRaw === 'string' ? JSON.parse(beforeRaw) : beforeRaw;
|
|
1086
|
-
const
|
|
1107
|
+
const beforeIds = new Set(beforeTabs.map(t => `${t.index}:${t.url}`));
|
|
1108
|
+
const beforeCount = beforeTabs.length;
|
|
1087
1109
|
|
|
1088
|
-
// Poll for new tab
|
|
1110
|
+
// Poll for new tab — detect by count increase + new entries (handles about:blank tabs)
|
|
1089
1111
|
const deadline = Date.now() + timeoutMs;
|
|
1090
1112
|
while (Date.now() < deadline) {
|
|
1091
1113
|
await new Promise(r => setTimeout(r, 500));
|
|
1092
1114
|
const nowRaw = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1093
1115
|
const nowTabs = typeof nowRaw === 'string' ? JSON.parse(nowRaw) : nowRaw;
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1116
|
+
if (nowTabs.length > beforeCount) {
|
|
1117
|
+
// Find the new tab(s) — could be about:blank initially (OAuth popups)
|
|
1118
|
+
for (const tab of nowTabs) {
|
|
1119
|
+
if (!beforeIds.has(`${tab.index}:${tab.url}`)) {
|
|
1120
|
+
// Wait briefly for about:blank to resolve to actual URL
|
|
1121
|
+
if (tab.url === 'about:blank') {
|
|
1122
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
1123
|
+
const refreshed = await extensionOrFallback("list_tabs", {}, () => safari.listTabs());
|
|
1124
|
+
const refreshedTabs = typeof refreshed === 'string' ? JSON.parse(refreshed) : refreshed;
|
|
1125
|
+
const resolved = refreshedTabs.find(t => t.index === tab.index);
|
|
1126
|
+
if (resolved && resolved.url !== 'about:blank') {
|
|
1127
|
+
if (urlContains && !resolved.url.includes(urlContains)) continue;
|
|
1128
|
+
await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
|
|
1129
|
+
return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
|
|
1130
|
+
}
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
if (urlContains && !tab.url.includes(urlContains)) continue;
|
|
1134
|
+
await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
|
|
1135
|
+
return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
|
|
1136
|
+
}
|
|
1100
1137
|
}
|
|
1101
1138
|
}
|
|
1102
1139
|
}
|
package/mcp-helpers.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// MCP Click/Interaction Helpers — injected into every page
|
|
2
|
+
// This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`.
|
|
3
|
+
// It is also used by the extension's execInTab for the AppleScript fallback path.
|
|
4
|
+
// IMPORTANT: Keep this file compatible with all browsers — no ES6+ modules, use var where possible.
|
|
5
|
+
|
|
6
|
+
if (window.__mcpVersion !== 5) {
|
|
7
|
+
window.__mcpVersion = 5;
|
|
8
|
+
window.__mcpRefs = window.__mcpRefs || {};
|
|
9
|
+
window.__mcpCachedRoots = null;
|
|
10
|
+
window.__mcpRootsDirty = true;
|
|
11
|
+
if (!window.__mcpRootsObserver) {
|
|
12
|
+
window.__mcpRootsObserver = new MutationObserver(function() { window.__mcpRootsDirty = true; });
|
|
13
|
+
window.__mcpRootsObserver.observe(document.documentElement, { childList: true, subtree: true });
|
|
14
|
+
}
|
|
15
|
+
window.mcpCollectRoots = function() {
|
|
16
|
+
if (!window.__mcpRootsDirty && window.__mcpCachedRoots) return window.__mcpCachedRoots;
|
|
17
|
+
var roots = [];
|
|
18
|
+
function collect(root) {
|
|
19
|
+
roots.push(root);
|
|
20
|
+
var all = root.querySelectorAll('*');
|
|
21
|
+
for (var i = 0; i < all.length; i++) {
|
|
22
|
+
if (all[i].shadowRoot) collect(all[i].shadowRoot);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
collect(document);
|
|
26
|
+
var iframes = document.querySelectorAll('iframe');
|
|
27
|
+
for (var i = 0; i < iframes.length; i++) {
|
|
28
|
+
try { var doc = iframes[i].contentDocument; if (doc) collect(doc); } catch (e) {}
|
|
29
|
+
}
|
|
30
|
+
window.__mcpCachedRoots = roots;
|
|
31
|
+
window.__mcpRootsDirty = false;
|
|
32
|
+
return roots;
|
|
33
|
+
};
|
|
34
|
+
window.mcpQuerySelectorDeep = function(selector) {
|
|
35
|
+
try {
|
|
36
|
+
var direct = document.querySelector(selector);
|
|
37
|
+
if (direct) return direct;
|
|
38
|
+
} catch (e) { return null; }
|
|
39
|
+
var roots = window.mcpCollectRoots();
|
|
40
|
+
for (var i = 1; i < roots.length; i++) {
|
|
41
|
+
try {
|
|
42
|
+
var found = roots[i].querySelector(selector);
|
|
43
|
+
if (found) return found;
|
|
44
|
+
} catch (e) {}
|
|
45
|
+
}
|
|
46
|
+
var iframes = document.querySelectorAll('iframe');
|
|
47
|
+
for (var i = 0; i < iframes.length; i++) {
|
|
48
|
+
try {
|
|
49
|
+
var doc = iframes[i].contentDocument;
|
|
50
|
+
if (doc) { var found = doc.querySelector(selector); if (found) return found; }
|
|
51
|
+
} catch (e) {}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
window.mcpElementFromPoint = function(x, y) {
|
|
56
|
+
var el = document.elementFromPoint(x, y);
|
|
57
|
+
while (el && el.shadowRoot) {
|
|
58
|
+
try {
|
|
59
|
+
if (typeof el.shadowRoot.elementFromPoint !== 'function') break;
|
|
60
|
+
var inner = el.shadowRoot.elementFromPoint(x, y);
|
|
61
|
+
if (!inner || inner === el) break;
|
|
62
|
+
el = inner;
|
|
63
|
+
} catch (e) { break; }
|
|
64
|
+
}
|
|
65
|
+
return el;
|
|
66
|
+
};
|
|
67
|
+
window.mcpIsVisible = function(el) {
|
|
68
|
+
if (!el || el.nodeType !== 1 || !el.isConnected) return false;
|
|
69
|
+
var cs = window.getComputedStyle(el);
|
|
70
|
+
if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || parseFloat(cs.opacity) === 0) return false;
|
|
71
|
+
var r = el.getBoundingClientRect();
|
|
72
|
+
return r.width > 0 && r.height > 0;
|
|
73
|
+
};
|
|
74
|
+
window.mcpIsActionable = function(el) {
|
|
75
|
+
if (!window.mcpIsVisible(el)) return false;
|
|
76
|
+
if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
|
|
77
|
+
if (el.matches && el.matches('input[type="hidden"]')) return false;
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
window.mcpPickActionable = function(el) {
|
|
81
|
+
var node = el && el.nodeType === 1 ? el : (el && el.parentElement) || null;
|
|
82
|
+
while (node) {
|
|
83
|
+
if (window.mcpIsActionable(node) && node.matches && node.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,option,[role],[onclick],[tabindex],[contenteditable=""],[contenteditable="true"]')) return node;
|
|
84
|
+
node = node.parentElement;
|
|
85
|
+
}
|
|
86
|
+
return el && el.nodeType === 1 ? el : null;
|
|
87
|
+
};
|
|
88
|
+
window.mcpFindByAttr = function(attr, value, selector) {
|
|
89
|
+
if (!value) return null;
|
|
90
|
+
var roots = window.mcpCollectRoots();
|
|
91
|
+
var sel = selector || ('[' + attr + ']');
|
|
92
|
+
for (var i = 0; i < roots.length; i++) {
|
|
93
|
+
var els = roots[i].querySelectorAll(sel);
|
|
94
|
+
for (var j = 0; j < els.length; j++) {
|
|
95
|
+
var current = attr === 'href' ? els[j].href : els[j].getAttribute(attr);
|
|
96
|
+
if (current === value && window.mcpIsVisible(els[j])) return els[j];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
};
|
|
101
|
+
window.mcpNormalizeText = function(value) {
|
|
102
|
+
return String(value || '').replace(/\s+/g, ' ').trim();
|
|
103
|
+
};
|
|
104
|
+
window.mcpResolveTarget = function(el) {
|
|
105
|
+
var base = window.mcpPickActionable(el) || el;
|
|
106
|
+
if (!base) return null;
|
|
107
|
+
try { base.scrollIntoView({ block: 'center', inline: 'center' }); } catch (e) {}
|
|
108
|
+
var r = base.getBoundingClientRect();
|
|
109
|
+
var dx = Math.min(12, Math.max(4, r.width / 4));
|
|
110
|
+
var dy = Math.min(12, Math.max(4, r.height / 4));
|
|
111
|
+
var pts = [
|
|
112
|
+
[r.left + r.width / 2, r.top + r.height / 2],
|
|
113
|
+
[r.left + dx, r.top + r.height / 2],
|
|
114
|
+
[r.right - dx, r.top + r.height / 2],
|
|
115
|
+
[r.left + r.width / 2, r.top + dy],
|
|
116
|
+
[r.left + r.width / 2, r.bottom - dy]
|
|
117
|
+
];
|
|
118
|
+
for (var i = 0; i < pts.length; i++) {
|
|
119
|
+
var x = pts[i][0], y = pts[i][1];
|
|
120
|
+
if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
|
|
121
|
+
var hit = window.mcpElementFromPoint(x, y);
|
|
122
|
+
if (!hit) continue;
|
|
123
|
+
if (base === hit || base.contains(hit) || hit.contains(base)) return window.mcpPickActionable(hit) || hit;
|
|
124
|
+
// Overlay pattern: transparent button/link overlays a text label (e.g. dropdown items).
|
|
125
|
+
// If the hit is an interactive element sharing a nearby common ancestor, prefer it.
|
|
126
|
+
var hitAction = window.mcpPickActionable(hit);
|
|
127
|
+
if (hitAction && hitAction !== base && hitAction.matches && hitAction.matches('button,a[href],[role="button"],[role="option"],[role="menuitem"]')) {
|
|
128
|
+
var p = base.parentElement;
|
|
129
|
+
for (var k = 0; k < 4 && p; k++) {
|
|
130
|
+
if (p.contains(hitAction)) return hitAction;
|
|
131
|
+
p = p.parentElement;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return base;
|
|
136
|
+
};
|
|
137
|
+
window.mcpClick = function(el) {
|
|
138
|
+
var target = window.mcpResolveTarget(el);
|
|
139
|
+
if (!window.mcpIsActionable(target)) return false;
|
|
140
|
+
var r = target.getBoundingClientRect();
|
|
141
|
+
var x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
142
|
+
var beforeUrl = location.href;
|
|
143
|
+
var anchor = target.closest ? target.closest('a[href]') : null;
|
|
144
|
+
var href = anchor && anchor.href && !anchor.href.startsWith('javascript:') ? anchor.href : '';
|
|
145
|
+
var s = { bubbles: true, cancelable: true, composed: true, view: window, clientX: x, clientY: y, button: 0, detail: 1 };
|
|
146
|
+
var p = { ...s, pointerId: 1, pointerType: 'mouse', isPrimary: true, width: 1, height: 1, pressure: 0.5 };
|
|
147
|
+
target.dispatchEvent(new PointerEvent('pointerover', { ...p, buttons: 0 }));
|
|
148
|
+
target.dispatchEvent(new MouseEvent('mouseover', { ...s, buttons: 0 }));
|
|
149
|
+
target.dispatchEvent(new PointerEvent('pointerenter', { ...p, buttons: 0 }));
|
|
150
|
+
target.dispatchEvent(new MouseEvent('mouseenter', { ...s, buttons: 0 }));
|
|
151
|
+
target.dispatchEvent(new PointerEvent('pointermove', { ...p, buttons: 0 }));
|
|
152
|
+
target.dispatchEvent(new MouseEvent('mousemove', { ...s, buttons: 0 }));
|
|
153
|
+
target.dispatchEvent(new PointerEvent('pointerdown', { ...p, buttons: 1 }));
|
|
154
|
+
target.dispatchEvent(new MouseEvent('mousedown', { ...s, buttons: 1 }));
|
|
155
|
+
if (target.focus) { try { target.focus({ preventScroll: true }); } catch (e) { try { target.focus(); } catch (_) {} } }
|
|
156
|
+
target.dispatchEvent(new PointerEvent('pointerup', { ...p, buttons: 0, pressure: 0 }));
|
|
157
|
+
target.dispatchEvent(new MouseEvent('mouseup', { ...s, buttons: 0 }));
|
|
158
|
+
try {
|
|
159
|
+
if (typeof target.click === 'function') {
|
|
160
|
+
target.click();
|
|
161
|
+
if (href && href !== beforeUrl) {
|
|
162
|
+
location.href = href;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {}
|
|
166
|
+
target.dispatchEvent(new MouseEvent('click', { ...s, buttons: 0 }));
|
|
167
|
+
if (href && href !== beforeUrl) {
|
|
168
|
+
location.href = href;
|
|
169
|
+
}
|
|
170
|
+
var form = target.closest ? target.closest('form') : null;
|
|
171
|
+
if (form && (target.type === 'submit' || (target.tagName === 'BUTTON' && target.type !== 'button' && target.type !== 'reset'))) {
|
|
172
|
+
try { form.requestSubmit ? form.requestSubmit(target.type === 'submit' ? target : undefined) : form.submit(); } catch (e) {}
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
};
|
|
176
|
+
window.mcpReactClick = function(el) {
|
|
177
|
+
var startNode = window.mcpResolveTarget(el) || el;
|
|
178
|
+
if (!startNode) return false;
|
|
179
|
+
function makeSynth(targetNode, type) {
|
|
180
|
+
var r = targetNode.getBoundingClientRect();
|
|
181
|
+
return { type: type || 'click', target: targetNode, currentTarget: targetNode,
|
|
182
|
+
clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, pageX: r.left + r.width / 2 + window.scrollX, pageY: r.top + r.height / 2 + window.scrollY,
|
|
183
|
+
preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new MouseEvent(type || 'click'), persist: function() {}, bubbles: true, cancelable: true };
|
|
184
|
+
}
|
|
185
|
+
// For React radio/checkbox inputs, onChange is the primary handler (not onClick)
|
|
186
|
+
var isToggle = startNode.tagName === 'INPUT' && (startNode.type === 'radio' || startNode.type === 'checkbox');
|
|
187
|
+
function tryOnChange(propsObj, targetNode) {
|
|
188
|
+
if (!isToggle || !propsObj.onChange) return false;
|
|
189
|
+
var newChecked = startNode.type === 'radio' ? true : !startNode.checked;
|
|
190
|
+
propsObj.onChange({ target: { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' },
|
|
191
|
+
currentTarget: targetNode, preventDefault: function() {}, stopPropagation: function() {}, nativeEvent: new Event('change'), persist: function() {}, bubbles: true, cancelable: true, type: 'change' });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
var node = startNode;
|
|
195
|
+
for (var depth = 0; depth < 15 && node; depth++) {
|
|
196
|
+
var pk = Object.keys(node).find(function(k) { return k.startsWith('__reactProps$'); });
|
|
197
|
+
if (pk && node[pk]) {
|
|
198
|
+
var props = node[pk];
|
|
199
|
+
if (tryOnChange(props, node)) return true;
|
|
200
|
+
if (props.onClick) { props.onClick(makeSynth(node, 'click')); return true; }
|
|
201
|
+
if (props.onMouseDown) { props.onMouseDown(makeSynth(node, 'mousedown')); return true; }
|
|
202
|
+
if (props.onPointerDown) { props.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
|
|
203
|
+
}
|
|
204
|
+
var fk = Object.keys(node).find(function(k) { return k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'); });
|
|
205
|
+
if (fk) {
|
|
206
|
+
var f = node[fk];
|
|
207
|
+
while (f) {
|
|
208
|
+
if (f.memoizedProps) {
|
|
209
|
+
if (tryOnChange(f.memoizedProps, node)) return true;
|
|
210
|
+
if (f.memoizedProps.onClick) { f.memoizedProps.onClick(makeSynth(node, 'click')); return true; }
|
|
211
|
+
if (f.memoizedProps.onMouseDown) { f.memoizedProps.onMouseDown(makeSynth(node, 'mousedown')); return true; }
|
|
212
|
+
if (f.memoizedProps.onPointerDown) { f.memoizedProps.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
|
|
213
|
+
}
|
|
214
|
+
f = f.return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
node = node.parentElement;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
};
|
|
221
|
+
window.mcpClickWithReact = function(el) {
|
|
222
|
+
var target = window.mcpResolveTarget(el) || el;
|
|
223
|
+
var reactFired = false;
|
|
224
|
+
try { reactFired = window.mcpReactClick(target); } catch (e) {}
|
|
225
|
+
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
|
226
|
+
if (!reactFired || anchor) window.mcpClick(target);
|
|
227
|
+
return target;
|
|
228
|
+
};
|
|
229
|
+
window.mcpFindRef = function(ref) {
|
|
230
|
+
var el = window.mcpQuerySelectorDeep('[data-mcp-ref="' + ref + '"]');
|
|
231
|
+
if (el) return el;
|
|
232
|
+
if (!window.__mcpRefs || !window.__mcpRefs[ref]) return null;
|
|
233
|
+
var m = window.__mcpRefs[ref];
|
|
234
|
+
if (m.id) { el = window.mcpFindByAttr('id', m.id); if (el) return el; }
|
|
235
|
+
if (m.testid) { el = window.mcpFindByAttr('data-testid', m.testid); if (el) return el; }
|
|
236
|
+
if (m.nameAttr) { el = window.mcpFindByAttr('name', m.nameAttr); if (el) return el; }
|
|
237
|
+
if (m.href) { el = window.mcpFindByAttr('href', m.href, 'a[href]'); if (el) return el; }
|
|
238
|
+
if (m.al) { el = window.mcpFindByAttr('aria-label', m.al); if (el) return el; }
|
|
239
|
+
if (m.ph) { el = window.mcpFindByAttr('placeholder', m.ph); if (el) return el; }
|
|
240
|
+
if (m.text) {
|
|
241
|
+
el = window.mcpFindText(m.text, true) || window.mcpFindText(m.text, false);
|
|
242
|
+
if (el) return el;
|
|
243
|
+
}
|
|
244
|
+
if (m.cx !== undefined && m.cy !== undefined) {
|
|
245
|
+
try {
|
|
246
|
+
window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2));
|
|
247
|
+
} catch (e) {}
|
|
248
|
+
el = window.mcpElementFromPoint(m.cx - window.scrollX, m.cy - window.scrollY);
|
|
249
|
+
if (el) return window.mcpPickActionable(el) || el;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
};
|
|
253
|
+
window.mcpFindText = function(text, exact) {
|
|
254
|
+
var needle = window.mcpNormalizeText(text);
|
|
255
|
+
var best = null, bestScore = Infinity;
|
|
256
|
+
function consider(node) {
|
|
257
|
+
var target = window.mcpPickActionable(node) || node;
|
|
258
|
+
if (!window.mcpIsVisible(target)) return;
|
|
259
|
+
var r = target.getBoundingClientRect();
|
|
260
|
+
var area = r.width * r.height;
|
|
261
|
+
var interactive = target.matches && target.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,[role=button],[role=link],[role=tab],[onclick],[tabindex]');
|
|
262
|
+
var score = area + (interactive ? 0 : 1000000);
|
|
263
|
+
if (score < bestScore) { best = target; bestScore = score; }
|
|
264
|
+
}
|
|
265
|
+
var roots = window.mcpCollectRoots();
|
|
266
|
+
for (var i = 0; i < roots.length; i++) {
|
|
267
|
+
var attrEls = roots[i].querySelectorAll('[aria-label],[placeholder],[title],[data-testid],[alt]');
|
|
268
|
+
for (var j = 0; j < attrEls.length; j++) {
|
|
269
|
+
var a = attrEls[j];
|
|
270
|
+
var vals = [a.getAttribute('aria-label'), a.getAttribute('placeholder'), a.getAttribute('title'), a.getAttribute('data-testid'), a.getAttribute('alt')];
|
|
271
|
+
for (var k = 0; k < vals.length; k++) {
|
|
272
|
+
var val = vals[k];
|
|
273
|
+
if (!val) continue;
|
|
274
|
+
var normalized = window.mcpNormalizeText(val);
|
|
275
|
+
if (exact ? normalized === needle : normalized.includes(needle)) { consider(a); break; }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
var tw = document.createTreeWalker(roots[i], NodeFilter.SHOW_TEXT, null);
|
|
279
|
+
while (tw.nextNode()) {
|
|
280
|
+
var n = tw.currentNode;
|
|
281
|
+
var t = window.mcpNormalizeText(n.textContent);
|
|
282
|
+
if (!t) continue;
|
|
283
|
+
if (exact ? (t !== needle) : !t.includes(needle)) continue;
|
|
284
|
+
if (n.parentElement) consider(n.parentElement);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!best) {
|
|
288
|
+
var allEls = document.querySelectorAll('*');
|
|
289
|
+
for (var i = 0; i < allEls.length; i++) {
|
|
290
|
+
var el = allEls[i];
|
|
291
|
+
var it = window.mcpNormalizeText(el.innerText);
|
|
292
|
+
if (!it) continue;
|
|
293
|
+
if (exact ? (it !== needle) : !it.includes(needle)) continue;
|
|
294
|
+
consider(el);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return best;
|
|
298
|
+
};
|
|
299
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"files": [
|
|
50
50
|
"index.js",
|
|
51
51
|
"safari.js",
|
|
52
|
+
"mcp-helpers.js",
|
|
52
53
|
"safari-helper",
|
|
53
54
|
"safari-helper.swift",
|
|
54
55
|
"extension/",
|
package/safari.js
CHANGED
|
@@ -8,6 +8,7 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, dirname } from "node:path";
|
|
10
10
|
import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
// Extension bridge is handled by index.js (WebSocket server on port 9223)
|
|
13
14
|
|
|
@@ -18,6 +19,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
18
19
|
// Persistent process — no subprocess spawn overhead (~5ms vs ~90ms)
|
|
19
20
|
let _helperProc = null;
|
|
20
21
|
const _helperQueue = []; // callbacks waiting for responses
|
|
22
|
+
let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — only kill after 3
|
|
21
23
|
|
|
22
24
|
// Reject all pending callbacks when helper crashes
|
|
23
25
|
function _drainHelperQueue(reason) {
|
|
@@ -335,9 +337,15 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
335
337
|
resolved = true;
|
|
336
338
|
const idx = _helperQueue.indexOf(cb);
|
|
337
339
|
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
340
|
+
_helperConsecutiveTimeouts++;
|
|
341
|
+
// Only kill the daemon after 3 consecutive timeouts (single slow script shouldn't kill concurrent ops)
|
|
342
|
+
if (_helperConsecutiveTimeouts >= 3) {
|
|
343
|
+
console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts — killing daemon`);
|
|
344
|
+
_helperProc?.kill();
|
|
345
|
+
_helperProc = null;
|
|
346
|
+
_helperConsecutiveTimeouts = 0;
|
|
347
|
+
setTimeout(startHelper, 100);
|
|
348
|
+
}
|
|
341
349
|
reject(new Error("safari-helper timeout"));
|
|
342
350
|
}, timeout);
|
|
343
351
|
|
|
@@ -345,6 +353,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
345
353
|
if (resolved) return;
|
|
346
354
|
resolved = true;
|
|
347
355
|
clearTimeout(timer);
|
|
356
|
+
_helperConsecutiveTimeouts = 0; // Reset on success
|
|
348
357
|
try {
|
|
349
358
|
const parsed = JSON.parse(line);
|
|
350
359
|
if (parsed.error) reject(new Error(parsed.error));
|
|
@@ -354,14 +363,21 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
354
363
|
}
|
|
355
364
|
}
|
|
356
365
|
|
|
357
|
-
|
|
358
|
-
if (!_helperProc || !_helperProc.stdin) {
|
|
359
|
-
|
|
360
|
-
_helperQueue.pop(); // Remove the callback we just pushed
|
|
366
|
+
// Check process availability BEFORE pushing to queue (prevents dangling callbacks)
|
|
367
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
368
|
+
clearTimeout(timer);
|
|
361
369
|
reject(new Error("safari-helper not available"));
|
|
362
370
|
return;
|
|
363
371
|
}
|
|
364
|
-
|
|
372
|
+
_helperQueue.push(cb);
|
|
373
|
+
try {
|
|
374
|
+
_helperProc.stdin.write(JSON.stringify({ script }) + "\n");
|
|
375
|
+
} catch (writeErr) {
|
|
376
|
+
const idx = _helperQueue.indexOf(cb);
|
|
377
|
+
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
reject(new Error("safari-helper write failed: " + writeErr.message));
|
|
380
|
+
}
|
|
365
381
|
});
|
|
366
382
|
}
|
|
367
383
|
|
|
@@ -622,17 +638,21 @@ export async function navigate(url) {
|
|
|
622
638
|
|
|
623
639
|
export async function goBack() {
|
|
624
640
|
// Navigate back + smart wait: check immediately, then poll only if loading
|
|
625
|
-
|
|
641
|
+
const result = await runJS(
|
|
626
642
|
`(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});})()`,
|
|
627
643
|
{ timeout: 10000 }
|
|
628
644
|
);
|
|
645
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
646
|
+
return result;
|
|
629
647
|
}
|
|
630
648
|
|
|
631
649
|
export async function goForward() {
|
|
632
|
-
|
|
650
|
+
const result = await runJS(
|
|
633
651
|
`(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});})()`,
|
|
634
652
|
{ timeout: 10000 }
|
|
635
653
|
);
|
|
654
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
655
|
+
return result;
|
|
636
656
|
}
|
|
637
657
|
|
|
638
658
|
export async function reload(hardReload = false) {
|
|
@@ -640,10 +660,12 @@ export async function reload(hardReload = false) {
|
|
|
640
660
|
await runJS(hardReload ? "location.reload(true)" : "location.reload()");
|
|
641
661
|
await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
|
|
642
662
|
// Poll readyState in a single call
|
|
643
|
-
|
|
663
|
+
const result = await runJS(
|
|
644
664
|
`(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});})()`,
|
|
645
665
|
{ timeout: 10000 }
|
|
646
666
|
);
|
|
667
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
668
|
+
return result;
|
|
647
669
|
}
|
|
648
670
|
|
|
649
671
|
// ========== PAGE INFO ==========
|
|
@@ -673,7 +695,12 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
|
|
|
673
695
|
|
|
674
696
|
// Inject click helpers ONCE per page (cached on window.__mcp)
|
|
675
697
|
// Includes: mcpClick (full event sequence), mcpReactClick (React Fiber), mcpFindText (fast TreeWalker)
|
|
676
|
-
|
|
698
|
+
// Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
|
|
699
|
+
const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
|
|
700
|
+
|
|
701
|
+
// NOTE: Helper code previously inlined here (~300 lines) is now in mcp-helpers.js
|
|
702
|
+
/* eslint-disable */
|
|
703
|
+
const _LEGACY_INLINE_START = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
|
|
677
704
|
window.__mcpRefs=window.__mcpRefs||{};
|
|
678
705
|
window.__mcpCachedRoots=null;
|
|
679
706
|
window.__mcpRootsDirty=true;
|
|
@@ -1233,7 +1260,7 @@ export async function fill({ selector, value, ref }) {
|
|
|
1233
1260
|
|
|
1234
1261
|
const fillResult = await runJS(
|
|
1235
1262
|
`(function(){` +
|
|
1236
|
-
`var el=document.querySelector('${selector.replace(/'/g, "\\'")}');` +
|
|
1263
|
+
`var el=document.querySelector('${selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}');` +
|
|
1237
1264
|
`if(!el)return 'Element not found';` +
|
|
1238
1265
|
`el.focus();el.click();` +
|
|
1239
1266
|
`var sel=window.getSelection();if(sel.rangeCount){var r=document.createRange();r.selectNodeContents(el);r.collapse(false);sel.removeAllRanges();sel.addRange(r);}` +
|
|
@@ -1618,15 +1645,19 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1618
1645
|
await osascript(
|
|
1619
1646
|
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${Math.min(Number(h) + 100, 5000)}}`
|
|
1620
1647
|
);
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1648
|
+
try {
|
|
1649
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1650
|
+
// Use do shell script to inherit osascript's Screen Recording permission
|
|
1651
|
+
await osascript(
|
|
1652
|
+
`do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
1653
|
+
{ timeout: 15000 }
|
|
1654
|
+
);
|
|
1655
|
+
} finally {
|
|
1656
|
+
// Always restore bounds — even if screencapture fails
|
|
1657
|
+
await osascript(
|
|
1658
|
+
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
|
|
1659
|
+
).catch(() => {});
|
|
1660
|
+
}
|
|
1630
1661
|
} else {
|
|
1631
1662
|
// Try direct execFile first (works if VS Code has Screen Recording permission)
|
|
1632
1663
|
try {
|
|
@@ -1773,7 +1804,7 @@ export async function screenshotElement({ selector }) {
|
|
|
1773
1804
|
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
1774
1805
|
const { x, y, w, h } = JSON.parse(bounds);
|
|
1775
1806
|
// Use sips to crop (macOS built-in)
|
|
1776
|
-
const toolbarHeight =
|
|
1807
|
+
const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
|
|
1777
1808
|
const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
|
|
1778
1809
|
await execFileAsync("sips", [
|
|
1779
1810
|
"-c", String(h), String(w),
|
|
@@ -1861,8 +1892,9 @@ export async function newTab(url = "") {
|
|
|
1861
1892
|
else { await osascript('tell application "Safari" to make new document'); }
|
|
1862
1893
|
}
|
|
1863
1894
|
}
|
|
1895
|
+
// Get count atomically from the same tell block as tab creation (avoids TOCTOU if user opens tabs concurrently)
|
|
1864
1896
|
const tabCount = await osascriptFast(`tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`);
|
|
1865
|
-
_activeTabIndex = Number(tabCount);
|
|
1897
|
+
_activeTabIndex = Number(tabCount); // New tab is always appended as last
|
|
1866
1898
|
_activeTabURL = url || null;
|
|
1867
1899
|
_lastResolveTime = Date.now();
|
|
1868
1900
|
// Wait for page load if URL given
|
|
@@ -2052,11 +2084,11 @@ export async function handleDialog({ action = "accept", text }) {
|
|
|
2052
2084
|
}
|
|
2053
2085
|
if (action === "accept") {
|
|
2054
2086
|
await runJS(
|
|
2055
|
-
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.alert;window.alert=function(){window.alert=window.__origAlert;};"
|
|
2087
|
+
"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;};"
|
|
2056
2088
|
);
|
|
2057
2089
|
} else {
|
|
2058
2090
|
await runJS(
|
|
2059
|
-
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
|
|
2091
|
+
"window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
|
|
2060
2092
|
);
|
|
2061
2093
|
}
|
|
2062
2094
|
return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
|
|
@@ -2100,19 +2132,23 @@ export async function getNetworkRequests({ limit = 50 } = {}) {
|
|
|
2100
2132
|
|
|
2101
2133
|
export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
|
|
2102
2134
|
if (sourceSelector && targetSelector) {
|
|
2103
|
-
const srcSel = sourceSelector.replace(/'/g, "\\'");
|
|
2104
|
-
const tgtSel = targetSelector.replace(/'/g, "\\'");
|
|
2135
|
+
const srcSel = sourceSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2136
|
+
const tgtSel = targetSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2105
2137
|
return runJS(
|
|
2106
2138
|
`(function(){` +
|
|
2107
2139
|
`var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
|
|
2108
2140
|
`if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
|
|
2109
2141
|
`var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
|
|
2110
2142
|
`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;` +
|
|
2143
|
+
`var dt=new DataTransfer();` +
|
|
2144
|
+
`src.dispatchEvent(new DragEvent('dragstart',{clientX:sx,clientY:sy,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2111
2145
|
`src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
2112
2146
|
`src.dispatchEvent(new MouseEvent('mousemove',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
2147
|
+
`tgt.dispatchEvent(new DragEvent('dragover',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2113
2148
|
`tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
2114
2149
|
`tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
2115
|
-
`tgt.dispatchEvent(new DragEvent('drop',{bubbles:true}));` +
|
|
2150
|
+
`tgt.dispatchEvent(new DragEvent('drop',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2151
|
+
`src.dispatchEvent(new DragEvent('dragend',{bubbles:true}));` +
|
|
2116
2152
|
`return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
|
|
2117
2153
|
);
|
|
2118
2154
|
}
|
|
@@ -2131,9 +2167,27 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
|
|
|
2131
2167
|
throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
|
|
2132
2168
|
}
|
|
2133
2169
|
|
|
2170
|
+
// ========== FILE PATH SAFETY ==========
|
|
2171
|
+
// Prevent reading sensitive system files via upload/paste tools
|
|
2172
|
+
function _validateFilePath(filePath) {
|
|
2173
|
+
const { resolve } = require("node:path");
|
|
2174
|
+
const resolved = resolve(filePath);
|
|
2175
|
+
if (resolved.includes('..')) throw new Error("Path traversal not allowed: " + filePath);
|
|
2176
|
+
const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
|
|
2177
|
+
const lower = resolved.toLowerCase();
|
|
2178
|
+
for (const b of blocked) {
|
|
2179
|
+
if (lower.includes(b)) throw new Error("Blocked: reading sensitive path " + filePath);
|
|
2180
|
+
}
|
|
2181
|
+
// Must be under /Users/ or /tmp/ or /var/folders/ (macOS temp)
|
|
2182
|
+
if (!resolved.startsWith('/Users/') && !resolved.startsWith('/tmp/') && !resolved.startsWith('/var/folders/') && !resolved.startsWith('/private/tmp/')) {
|
|
2183
|
+
throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2134
2187
|
// ========== UPLOAD FILE ==========
|
|
2135
2188
|
|
|
2136
2189
|
export async function uploadFile({ selector, filePath }) {
|
|
2190
|
+
_validateFilePath(filePath);
|
|
2137
2191
|
// Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
|
|
2138
2192
|
// NO file dialog, NO System Events, NO focus stealing
|
|
2139
2193
|
|
|
@@ -2251,6 +2305,7 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2251
2305
|
// ========== PASTE IMAGE FROM FILE ==========
|
|
2252
2306
|
|
|
2253
2307
|
export async function pasteImageFromFile({ filePath }) {
|
|
2308
|
+
_validateFilePath(filePath);
|
|
2254
2309
|
// Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
|
|
2255
2310
|
const { extname } = await import("node:path");
|
|
2256
2311
|
const ext = extname(filePath).toLowerCase().replace(".", "");
|
|
@@ -2351,9 +2406,9 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
|
|
|
2351
2406
|
|
|
2352
2407
|
export async function resetEmulation() {
|
|
2353
2408
|
await refreshTargetWindow();
|
|
2354
|
-
// Reset user agent
|
|
2409
|
+
// Reset user agent — remove the defineProperty override set by emulate()
|
|
2355
2410
|
await runJS(
|
|
2356
|
-
"
|
|
2411
|
+
"try{var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent');if(d){Object.defineProperty(navigator,'userAgent',d);}else{delete navigator.userAgent;}}catch(_){}"
|
|
2357
2412
|
);
|
|
2358
2413
|
// Maximize window
|
|
2359
2414
|
await osascript(
|
|
@@ -2439,9 +2494,14 @@ export async function savePDF({ path: pdfPath }) {
|
|
|
2439
2494
|
// instead of guessing CSS selectors. Much faster, no hallucination risk.
|
|
2440
2495
|
|
|
2441
2496
|
let _snapshotGen = 0;
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2497
|
+
// getNextSnapshotGen is used by the MCP tool path (index.js) to reserve a gen for the extension.
|
|
2498
|
+
// If the extension fails and falls back to takeSnapshot(), takeSnapshot uses _snapshotGen directly
|
|
2499
|
+
// (which was already incremented by getNextSnapshotGen) — so no double-increment occurs.
|
|
2500
|
+
export function getNextSnapshotGen() { return _snapshotGen++; }
|
|
2501
|
+
|
|
2502
|
+
export async function takeSnapshot({ selector, _gen } = {}) {
|
|
2503
|
+
// Use provided gen (from tool path) or allocate a new one (direct call)
|
|
2504
|
+
const gen = _gen != null ? _gen : _snapshotGen++;
|
|
2445
2505
|
const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
|
|
2446
2506
|
|
|
2447
2507
|
const result = await runJS(
|
|
@@ -2793,7 +2853,13 @@ export async function importStorageState({ state }) {
|
|
|
2793
2853
|
const parsed = typeof state === "string" ? JSON.parse(state) : state;
|
|
2794
2854
|
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
2795
2855
|
const cmds = [];
|
|
2796
|
-
|
|
2856
|
+
// Cookies must be set one at a time — document.cookie only accepts one cookie per assignment
|
|
2857
|
+
if (parsed.cookies) {
|
|
2858
|
+
const cookiePairs = String(parsed.cookies).split(/;\s*/);
|
|
2859
|
+
for (const pair of cookiePairs) {
|
|
2860
|
+
if (pair.trim()) cmds.push(`document.cookie='${esc(pair.trim())}'`);
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2797
2863
|
if (parsed.localStorage) {
|
|
2798
2864
|
for (const [k, v] of Object.entries(parsed.localStorage)) {
|
|
2799
2865
|
cmds.push(`localStorage.setItem('${esc(k)}','${esc(v)}')`);
|
|
@@ -2804,7 +2870,12 @@ export async function importStorageState({ state }) {
|
|
|
2804
2870
|
cmds.push(`sessionStorage.setItem('${esc(k)}','${esc(v)}')`);
|
|
2805
2871
|
}
|
|
2806
2872
|
}
|
|
2807
|
-
|
|
2873
|
+
// Use runJSLarge for large sessions (many cookies/localStorage keys can exceed 260KB limit of runJS)
|
|
2874
|
+
const script = cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'";
|
|
2875
|
+
if (script.length > 200000) {
|
|
2876
|
+
return runJSLarge(script, { timeout: 30000 });
|
|
2877
|
+
}
|
|
2878
|
+
return runJS(script);
|
|
2808
2879
|
}
|
|
2809
2880
|
|
|
2810
2881
|
// ========== CLIPBOARD ==========
|
|
@@ -2926,8 +2997,9 @@ export async function clearNetworkMocks() {
|
|
|
2926
2997
|
// ========== WAIT FOR TIME ==========
|
|
2927
2998
|
|
|
2928
2999
|
export async function waitForTime({ ms }) {
|
|
2929
|
-
|
|
2930
|
-
|
|
3000
|
+
const capped = Math.min(Number(ms) || 0, 60000); // Cap at 60 seconds
|
|
3001
|
+
await new Promise((r) => setTimeout(r, capped));
|
|
3002
|
+
return capped < Number(ms) ? `Waited ${capped}ms (capped from ${ms}ms — max 60s)` : `Waited ${ms}ms`;
|
|
2931
3003
|
}
|
|
2932
3004
|
|
|
2933
3005
|
// ========== NETWORK CAPTURE (Detailed) ==========
|
|
@@ -3368,8 +3440,9 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
3368
3440
|
const safeUrl = targetUrl.replace(/"/g, '\\"');
|
|
3369
3441
|
const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
|
|
3370
3442
|
await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
|
|
3443
|
+
_activeTabURL = targetUrl;
|
|
3371
3444
|
// Single JS call: poll readyState + return page data — 1 call instead of 30+
|
|
3372
|
-
|
|
3445
|
+
const navResult = await runJS(
|
|
3373
3446
|
`(async function(){
|
|
3374
3447
|
for(var i=0;i<60;i++){
|
|
3375
3448
|
if(document.readyState==='complete')break;
|
|
@@ -3379,6 +3452,12 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
3379
3452
|
})()`,
|
|
3380
3453
|
{ timeout: 30000 }
|
|
3381
3454
|
);
|
|
3455
|
+
// Update _activeTabURL with the actual URL after navigation
|
|
3456
|
+
try {
|
|
3457
|
+
const parsed = JSON.parse(navResult);
|
|
3458
|
+
if (parsed.url && parsed.url !== 'about:blank') _activeTabURL = parsed.url;
|
|
3459
|
+
} catch {}
|
|
3460
|
+
return navResult;
|
|
3382
3461
|
}
|
|
3383
3462
|
|
|
3384
3463
|
// Click + wait for navigation or element — common after clicking a link/button
|
|
@@ -3474,11 +3553,6 @@ export async function analyzePage() {
|
|
|
3474
3553
|
}
|
|
3475
3554
|
|
|
3476
3555
|
// ========== GRACEFUL SHUTDOWN ==========
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
}
|
|
3481
|
-
|
|
3482
|
-
process.on("exit", cleanup);
|
|
3483
|
-
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
3484
|
-
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
3556
|
+
// NOTE: Signal handlers are registered at the top of the file (cleanupHelper + process.exit).
|
|
3557
|
+
// _drainHelperQueue is called from cleanupHelper via process.on("exit").
|
|
3558
|
+
process.on("exit", () => { _drainHelperQueue("shutting down"); });
|