safari-mcp 2.7.8 → 2.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -426,13 +426,22 @@ The extension requires a one-time build with Xcode (free, included with macOS).
426
426
  # 1. Clone the repo (the npm package does not include the Xcode project)
427
427
  git clone https://github.com/achiya-automation/safari-mcp.git
428
428
  cd safari-mcp
429
+
430
+ # 2. Build the extension
429
431
  xcodebuild -project "xcode/Safari MCP/Safari MCP.xcodeproj" \
430
432
  -scheme "Safari MCP (macOS)" -configuration Release build
431
433
 
432
- # 2. Find and open the built app
433
- open ~/Library/Developer/Xcode/DerivedData/Safari_MCP-*/Build/Products/Release/Safari\ MCP.app
434
+ # 3. Ad-hoc sign the built app so Safari will load it
435
+ # (xcodebuild without a signing identity produces a bundle Safari silently rejects)
436
+ APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Safari_MCP-*/Build/Products/Release -name "Safari MCP.app" -maxdepth 2 | head -1)
437
+ codesign --sign - --force --deep "$APP_PATH"
438
+
439
+ # 4. Open the app (needed once so Safari registers the extension)
440
+ open "$APP_PATH"
434
441
  ```
435
442
 
443
+ Alternatively, open `xcode/Safari MCP/Safari MCP.xcodeproj` directly in Xcode, select your Apple ID under Signing & Capabilities, and click Run. A free personal Apple Developer account is sufficient for local use.
444
+
436
445
  Then in Safari:
437
446
  1. Safari → Settings → Advanced → enable **Show features for web developers**
438
447
  2. Safari → Develop → **Allow Unsigned Extensions** (required each Safari restart)
@@ -456,8 +465,21 @@ Safari MCP needs these one-time permissions:
456
465
  | Permission | Where | Why |
457
466
  |-----------|-------|-----|
458
467
  | JavaScript from Apple Events | Safari → Develop menu | Required for `do JavaScript` |
459
- | Screen Recording | System Settings → Privacy | Required for `safari_screenshot` |
460
- | Accessibility | System Settings → Privacy | Required for `safari_save_pdf` only |
468
+ | Automation → Safari | System Settings → Privacy & Security → Automation | Required for all AppleScript-backed tools |
469
+ | Screen Recording | System Settings → Privacy & Security → Screen Recording | Required for `safari_screenshot` |
470
+ | Accessibility | System Settings → Privacy & Security → Accessibility | Required for `safari_save_pdf` only |
471
+
472
+ ### Granting Automation → Safari (important for IDE users)
473
+
474
+ macOS TCC grants Automation permission to the **parent process** that spawns the MCP server, not to `safari-mcp` itself. So you need to grant **Automation → Safari** to the app that runs Claude Code / Cursor / Windsurf — typically **Visual Studio Code** or **Terminal**.
475
+
476
+ If the permission dialog never appears automatically, run this command once from a Terminal that already has Automation permission:
477
+
478
+ ```bash
479
+ osascript -e 'tell application "Safari" to get URL of current tab of window 1'
480
+ ```
481
+
482
+ That call registers the Terminal app in the Automation database and then triggers the prompt for Safari. After you approve it, subsequent MCP calls from any child process chain will work.
461
483
 
462
484
  ---
463
485
 
@@ -466,6 +488,7 @@ Safari MCP needs these one-time permissions:
466
488
  | Issue | Fix |
467
489
  |-------|-----|
468
490
  | "AppleScript error" | Enable "Allow JavaScript from Apple Events" in Safari → Develop |
491
+ | "Not authorized to send Apple events to Safari" | Grant Automation → Safari to your IDE (see above) |
469
492
  | Screenshots empty | Grant Screen Recording permission to Terminal/VS Code |
470
493
  | Tab not found | Call `safari_list_tabs` to refresh tab indices |
471
494
  | Hebrew keyboard issues | All typing uses JS events — immune to keyboard layout |
@@ -515,7 +538,7 @@ Your support funds:
515
538
 
516
539
  ## Community
517
540
 
518
- **2,000+ weekly npm downloads** — developers are using Safari MCP to build AI agents on macOS.
541
+ **2,000+ monthly npm downloads** — developers are using Safari MCP to build AI agents on macOS.
519
542
 
520
543
  - [GitHub Discussions](https://github.com/achiya-automation/safari-mcp/discussions) — ask questions, share use cases
521
544
  - [Issues](https://github.com/achiya-automation/safari-mcp/issues) — bug reports and feature requests
@@ -37,42 +37,6 @@
37
37
  <span class="slider"></span>
38
38
  </label>
39
39
  </div>
40
- <script>
41
- // Inline script — cannot be cached separately by Safari
42
- function setStatus(c, t, i) {
43
- document.getElementById("statusDot").className = "dot " + c;
44
- document.getElementById("statusText").textContent = t;
45
- document.getElementById("info").textContent = i;
46
- }
47
- async function getVal(k, d) {
48
- try { const r = await browser.storage.local.get(k); return r[k] !== undefined ? r[k] : d; }
49
- catch { return d; }
50
- }
51
- async function check() {
52
- const on = await getVal("mcpEnabled", true);
53
- document.getElementById("enableToggle").checked = on;
54
- if (!on) { setStatus("paused", "Paused", "Toggle to resume"); return; }
55
- // Ask background script for real-time status first
56
- try {
57
- const resp = await browser.runtime.sendMessage({ action: "getStatus" });
58
- if (resp) {
59
- if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
60
- if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
61
- }
62
- } catch {}
63
- // Fallback: read from storage
64
- const s = await getVal("mcpStatus", null);
65
- if (s === "connected") setStatus("connected", "Connected", "Port 9224");
66
- else if (s === "paused") setStatus("paused", "Paused", "Toggle to resume");
67
- else if (s === "checking") setStatus("checking", "Connecting...", "Trying port 9224...");
68
- else setStatus("disconnected", "Not connected", "Start the MCP server to connect");
69
- }
70
- document.getElementById("enableToggle").addEventListener("change", async (e) => {
71
- await browser.storage.local.set({ mcpEnabled: e.target.checked });
72
- try { browser.runtime.sendMessage({ action: "setEnabled", enabled: e.target.checked }); } catch {}
73
- setTimeout(check, 500);
74
- });
75
- check();
76
- </script>
40
+ <script src="popup.js"></script>
77
41
  </body>
78
42
  </html>
@@ -0,0 +1,34 @@
1
+ function setStatus(c, t, i) {
2
+ document.getElementById("statusDot").className = "dot " + c;
3
+ document.getElementById("statusText").textContent = t;
4
+ document.getElementById("info").textContent = i;
5
+ }
6
+ async function getVal(k, d) {
7
+ try { const r = await browser.storage.local.get(k); return r[k] !== undefined ? r[k] : d; }
8
+ catch { return d; }
9
+ }
10
+ async function check() {
11
+ const on = await getVal("mcpEnabled", true);
12
+ document.getElementById("enableToggle").checked = on;
13
+ if (!on) { setStatus("paused", "Paused", "Toggle to resume"); return; }
14
+ // Ask background script for real-time status first
15
+ try {
16
+ const resp = await browser.runtime.sendMessage({ action: "getStatus" });
17
+ if (resp) {
18
+ if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
19
+ if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
20
+ }
21
+ } catch {}
22
+ // Fallback: read from storage
23
+ const s = await getVal("mcpStatus", null);
24
+ if (s === "connected") setStatus("connected", "Connected", "Port 9224");
25
+ else if (s === "paused") setStatus("paused", "Paused", "Toggle to resume");
26
+ else if (s === "checking") setStatus("checking", "Connecting...", "Trying port 9224...");
27
+ else setStatus("disconnected", "Not connected", "Start the MCP server to connect");
28
+ }
29
+ document.getElementById("enableToggle").addEventListener("change", async (e) => {
30
+ await browser.storage.local.set({ mcpEnabled: e.target.checked });
31
+ try { browser.runtime.sendMessage({ action: "setEnabled", enabled: e.target.checked }); } catch {}
32
+ setTimeout(check, 500);
33
+ });
34
+ check();
package/index.js CHANGED
@@ -13,9 +13,10 @@ 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";
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
+ import { homedir } from "node:os";
19
20
 
20
21
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS
21
22
 
@@ -51,6 +52,36 @@ try {
51
52
  // ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
52
53
  const SESSION_ID = randomUUID().slice(0, 8);
53
54
 
55
+ // ========== PERSISTENT TAB OWNERSHIP ==========
56
+ // The in-memory _ownedTabURLs set is wiped when the MCP process restarts
57
+ // (Claude Code periodically recycles MCP servers). Without persistence, every
58
+ // restart re-triggers "Tab safety: no tabs opened yet" errors forcing a
59
+ // re-open of every tab. Persist the set to a JSON file with a TTL so tabs
60
+ // remain "owned" across process restarts for up to OWNERSHIP_TTL_MS.
61
+ const OWNERSHIP_DIR = join(homedir(), ".safari-mcp");
62
+ const OWNERSHIP_FILE = join(OWNERSHIP_DIR, "owned-tabs.json");
63
+ const OWNERSHIP_TTL_MS = 30 * 60 * 1000; // 30 minutes
64
+
65
+ function _loadOwnershipFile() {
66
+ try {
67
+ if (!existsSync(OWNERSHIP_FILE)) return [];
68
+ const raw = readFileSync(OWNERSHIP_FILE, "utf8");
69
+ const data = JSON.parse(raw);
70
+ if (!Array.isArray(data)) return [];
71
+ const cutoff = Date.now() - OWNERSHIP_TTL_MS;
72
+ return data.filter(e => e && typeof e.url === "string" && typeof e.ts === "number" && e.ts > cutoff);
73
+ } catch { return []; }
74
+ }
75
+
76
+ function _saveOwnershipFile(urls) {
77
+ try {
78
+ if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
79
+ const now = Date.now();
80
+ const entries = Array.from(urls).map(url => ({ url, ts: now }));
81
+ writeFileSync(OWNERSHIP_FILE, JSON.stringify(entries), { mode: 0o600 });
82
+ } catch { /* best-effort */ }
83
+ }
84
+
54
85
  // ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
55
86
  const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
56
87
  const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
@@ -63,7 +94,8 @@ const _openedTabs = new Map();
63
94
  // Tracks URLs of tabs opened by this MCP session.
64
95
  // Any tool that modifies a tab (navigate, click, fill, etc.) is blocked
65
96
  // unless the current tab was opened via safari_new_tab.
66
- const _ownedTabURLs = new Set();
97
+ // Hydrated from ~/.safari-mcp/owned-tabs.json so ownership survives MCP restarts.
98
+ const _ownedTabURLs = new Set(_loadOwnershipFile().map(e => e.url));
67
99
 
68
100
  function _isURLOwned(url) {
69
101
  if (!url) return false;
@@ -96,11 +128,17 @@ function _isURLOwned(url) {
96
128
  }
97
129
 
98
130
  function _addOwnedURL(url) {
99
- if (url && url !== 'about:blank' && url !== 'favorites://') _ownedTabURLs.add(url);
131
+ if (url && url !== 'about:blank' && url !== 'favorites://') {
132
+ _ownedTabURLs.add(url);
133
+ _saveOwnershipFile(_ownedTabURLs);
134
+ }
100
135
  }
101
136
 
102
137
  function _removeOwnedURL(url) {
103
- if (url) _ownedTabURLs.delete(url);
138
+ if (url) {
139
+ _ownedTabURLs.delete(url);
140
+ _saveOwnershipFile(_ownedTabURLs);
141
+ }
104
142
  }
105
143
 
106
144
  function _updateOwnedURL(oldUrl, newUrl) {
@@ -147,7 +185,7 @@ let _lastMemoryWarningTime = 0;
147
185
 
148
186
  function _getWebKitMemoryMB() {
149
187
  try {
150
- const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
188
+ const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
151
189
  if (!pids) return 0;
152
190
  const pidList = pids.split("\n").join(",");
153
191
  const psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
@@ -953,6 +991,24 @@ server.tool(
953
991
  }
954
992
  );
955
993
 
994
+ server.tool(
995
+ "safari_native_hover",
996
+ "OS-level mouse hover via macOS CGEvent — moves the real cursor to an element to trigger native :hover / mouseenter handlers. Use for obfuscated UIs where JS-dispatched mouseenter isn't enough, like Discord server sidebars (tooltips only appear on real hover) or portal-rendered tooltips. After hover, call safari_wait_for or safari_evaluate to read the tooltip. Dwells for dwellMs to let tooltips render, then restores the original cursor position by default. Requires Safari window to be visible.",
997
+ {
998
+ ref: z.string().optional().describe("Ref ID from safari_snapshot"),
999
+ selector: z.string().optional().describe("CSS selector"),
1000
+ text: z.string().optional().describe("Visible text to find and hover"),
1001
+ x: z.coerce.number().optional().describe("Viewport X coordinate"),
1002
+ y: z.coerce.number().optional().describe("Viewport Y coordinate"),
1003
+ dwellMs: z.coerce.number().optional().default(500).describe("Milliseconds to dwell over the element so tooltips render (clamped 0-5000)"),
1004
+ restoreMouse: z.boolean().optional().default(true).describe("Restore cursor to original position after dwell"),
1005
+ },
1006
+ async (args) => {
1007
+ const result = await safari.nativeHover(args);
1008
+ return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
1009
+ }
1010
+ );
1011
+
956
1012
  // ========== FORM INPUT ==========
957
1013
 
958
1014
  server.tool(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.7.8",
3
+ "version": "2.7.10",
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",
@@ -71,5 +71,9 @@
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "^1.12.1",
73
73
  "ws": "^8.19.0"
74
+ },
75
+ "overrides": {
76
+ "hono": "^4.12.12",
77
+ "@hono/node-server": "^1.19.13"
74
78
  }
75
79
  }
package/safari-helper CHANGED
Binary file
@@ -14,6 +14,53 @@ import Darwin
14
14
  import CoreGraphics
15
15
  import AppKit
16
16
 
17
+ // ========== CGEvent Native Hover ==========
18
+ // Moves the mouse cursor to a target position to trigger native :hover / mouseenter
19
+ // without clicking. Used for revealing tooltips on obfuscated UIs (Discord sidebar,
20
+ // virtualized server lists, custom React tooltips) where JS-dispatched mouseenter
21
+ // events aren't enough because the rendering depends on CSS :hover or real pointer
22
+ // position. Optionally restores the original cursor position after dwell.
23
+ func performNativeHover(x: Double, y: Double, windowId: Int64 = 0, dwellMs: Int = 500, restoreMouse: Bool = true) -> [String: Any] {
24
+ let point = CGPoint(x: x, y: y)
25
+ let savedPosition = CGEvent(source: nil)?.location ?? CGPoint.zero
26
+
27
+ // Get Safari PID for process-targeted event posting (background hover, no focus steal)
28
+ var safariPID: pid_t = 0
29
+ if windowId > 0 {
30
+ let ws = NSWorkspace.shared
31
+ for app in ws.runningApplications {
32
+ if app.bundleIdentifier == "com.apple.Safari" {
33
+ safariPID = app.processIdentifier
34
+ break
35
+ }
36
+ }
37
+ }
38
+
39
+ let kWindowField = CGEventField(rawValue: 91)! // windowUnderMousePointer
40
+ let kWindowHandlerField = CGEventField(rawValue: 92)! // windowThatCanHandleThisEvent
41
+
42
+ func postMove(_ position: CGPoint) {
43
+ guard let ev = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: position, mouseButton: .left) else { return }
44
+ if windowId > 0 && safariPID > 0 {
45
+ ev.setIntegerValueField(kWindowField, value: windowId)
46
+ ev.setIntegerValueField(kWindowHandlerField, value: windowId)
47
+ ev.postToPid(safariPID)
48
+ } else {
49
+ ev.post(tap: .cghidEventTap)
50
+ }
51
+ }
52
+
53
+ postMove(point)
54
+ let ms = max(0, min(dwellMs, 5000)) // clamp 0-5000ms to prevent runaway blocking
55
+ usleep(UInt32(ms * 1000))
56
+ if restoreMouse {
57
+ postMove(savedPosition)
58
+ }
59
+
60
+ let targetInfo = windowId > 0 ? " (window \(windowId), background)" : ""
61
+ return ["result": "hovered at (\(Int(x)),\(Int(y))) for \(ms)ms\(targetInfo)\(restoreMouse ? " (mouse restored)" : "")"]
62
+ }
63
+
17
64
  // ========== CGEvent Native Click ==========
18
65
  // Performs a REAL OS-level mouse click that produces isTrusted: true in the browser.
19
66
  // Requires Accessibility permissions (same as AppleScript automation).
@@ -256,6 +303,18 @@ while let line = readLine(strippingNewline: true) {
256
303
  continue
257
304
  }
258
305
 
306
+ // Handle CGEvent hover command — native mouse move to trigger real :hover / mouseenter
307
+ // {"hover": {"x": 500, "y": 300, "windowId": 4127, "dwellMs": 500, "restoreMouse": true}}
308
+ if let hoverData = json["hover"] as? [String: Any],
309
+ let x = hoverData["x"] as? Double,
310
+ let y = hoverData["y"] as? Double {
311
+ let windowId = Int64((hoverData["windowId"] as? Int) ?? 0)
312
+ let dwellMs = (hoverData["dwellMs"] as? Int) ?? 500
313
+ let restoreMouse = (hoverData["restoreMouse"] as? Bool) ?? true
314
+ respond(performNativeHover(x: x, y: y, windowId: windowId, dwellMs: dwellMs, restoreMouse: restoreMouse))
315
+ continue
316
+ }
317
+
259
318
  // Handle CGEvent keyboard command
260
319
  // {"keyboard": {"keyCode": 9, "flags": ["cmd"], "windowId": 4127}}
261
320
  // keyCode 9 = V, flags: cmd/shift/alt/ctrl
package/safari.js CHANGED
@@ -545,6 +545,44 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
545
545
  });
546
546
  }
547
547
 
548
+ // Sends a CGEvent hover command to the Swift helper daemon.
549
+ // Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
550
+ function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
551
+ return new Promise((resolve, reject) => {
552
+ if (!_helperProc) startHelper();
553
+ if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
554
+ reject(new Error("safari-helper not available for native hover"));
555
+ return;
556
+ }
557
+ let resolved = false;
558
+ const timer = setTimeout(() => {
559
+ if (resolved) return;
560
+ resolved = true;
561
+ const idx = _helperQueue.indexOf(cb);
562
+ if (idx >= 0) _helperQueue[idx] = () => {};
563
+ reject(new Error("native hover timeout"));
564
+ }, timeout);
565
+
566
+ function cb(line) {
567
+ if (resolved) return;
568
+ resolved = true;
569
+ clearTimeout(timer);
570
+ try {
571
+ const parsed = JSON.parse(line);
572
+ if (parsed.error) reject(new Error(parsed.error));
573
+ else resolve(parsed.result ?? "");
574
+ } catch {
575
+ resolve(line);
576
+ }
577
+ }
578
+
579
+ _helperQueue.push(cb);
580
+ const cmd = { hover: { x, y, dwellMs, restoreMouse } };
581
+ if (windowId) cmd.hover.windowId = windowId;
582
+ _helperProc.stdin.write(JSON.stringify(cmd) + "\n");
583
+ });
584
+ }
585
+
548
586
  // Sends a CGEvent keyboard command to the Swift helper daemon.
549
587
  // No focus stealing — sends key events directly to the target window via PID.
550
588
  function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
@@ -1120,6 +1158,88 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
1120
1158
  return `${clickType}: ${label} at screen (${screenX},${screenY})`;
1121
1159
  }
1122
1160
 
1161
+ // ========== NATIVE HOVER (OS-level CGEvent mouse move — triggers real :hover and mouseenter) ==========
1162
+ // JS-dispatched mouseenter events work for most React components, but some UIs
1163
+ // (Discord sidebar, virtualized CSS :hover tooltips, custom portal-rendered
1164
+ // tooltips) only respond to a real OS-level cursor position. This function
1165
+ // moves the physical cursor to the target, dwells for tooltips to render,
1166
+ // then optionally restores the cursor to its original position.
1167
+ export async function nativeHover({ selector, text, x, y, ref, dwellMs = 500, restoreMouse = true }) {
1168
+ await ensureHelpers();
1169
+
1170
+ // Step 1: Get element's viewport coordinates via JavaScript
1171
+ let viewportCoords;
1172
+ if (ref || selector || text) {
1173
+ let jsExpr;
1174
+ if (ref) {
1175
+ jsExpr = `(function(){
1176
+ var el = mcpFindRef('${ref}');
1177
+ if (!el) return JSON.stringify({error: 'Element not found: ref=${ref}'});
1178
+ el.scrollIntoView({block:'center', behavior:'instant'});
1179
+ var rect = el.getBoundingClientRect();
1180
+ return JSON.stringify({
1181
+ x: Math.round(rect.left + rect.width / 2),
1182
+ y: Math.round(rect.top + rect.height / 2),
1183
+ tag: el.tagName,
1184
+ text: (el.innerText || el.textContent || '').trim().substring(0, 50)
1185
+ });
1186
+ })()`;
1187
+ } else if (selector) {
1188
+ const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1189
+ jsExpr = `(function(){
1190
+ var el = document.querySelector('${sel}');
1191
+ if (!el) return JSON.stringify({error: 'Element not found: ${sel}'});
1192
+ el.scrollIntoView({block:'center', behavior:'instant'});
1193
+ var rect = el.getBoundingClientRect();
1194
+ return JSON.stringify({
1195
+ x: Math.round(rect.left + rect.width / 2),
1196
+ y: Math.round(rect.top + rect.height / 2),
1197
+ tag: el.tagName,
1198
+ text: (el.innerText || el.textContent || '').trim().substring(0, 50)
1199
+ });
1200
+ })()`;
1201
+ } else {
1202
+ const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1203
+ jsExpr = `(function(){
1204
+ var el = mcpFindText('${safeText}', true) || mcpFindText('${safeText}', false);
1205
+ if (!el) return JSON.stringify({error: 'Element not found with text: ${safeText}'});
1206
+ el.scrollIntoView({block:'center', behavior:'instant'});
1207
+ var rect = el.getBoundingClientRect();
1208
+ return JSON.stringify({
1209
+ x: Math.round(rect.left + rect.width / 2),
1210
+ y: Math.round(rect.top + rect.height / 2),
1211
+ tag: el.tagName,
1212
+ text: (el.innerText || el.textContent || '').trim().substring(0, 50)
1213
+ });
1214
+ })()`;
1215
+ }
1216
+
1217
+ const result = await runJS(jsExpr);
1218
+ try {
1219
+ viewportCoords = JSON.parse(result);
1220
+ } catch {
1221
+ throw new Error("Failed to get element coordinates: " + result);
1222
+ }
1223
+ if (viewportCoords.error) {
1224
+ throw new Error(viewportCoords.error);
1225
+ }
1226
+ } else if (x !== undefined && y !== undefined) {
1227
+ viewportCoords = { x: Number(x), y: Number(y), tag: 'point', text: '' };
1228
+ } else {
1229
+ throw new Error("nativeHover requires selector, text, ref, or x/y coordinates");
1230
+ }
1231
+
1232
+ const geo = await _getSafariWindowGeometry();
1233
+ const screenX = geo.windowX + viewportCoords.x;
1234
+ const screenY = geo.windowY + geo.toolbarHeight + viewportCoords.y;
1235
+
1236
+ if (!geo.windowId) throw new Error("Cannot native-hover without Safari window ID — would move mouse and steal focus");
1237
+ await _helperNativeHover(screenX, screenY, geo.windowId, dwellMs, restoreMouse);
1238
+
1239
+ const label = viewportCoords.tag + (viewportCoords.text ? ` "${viewportCoords.text}"` : '');
1240
+ return `Native hovered: ${label} at screen (${screenX},${screenY}) for ${dwellMs}ms${restoreMouse ? ' (mouse restored)' : ''}`;
1241
+ }
1242
+
1123
1243
  // ========== FORM INPUT ==========
1124
1244
 
1125
1245
  export async function fill({ selector, value, ref }) {