safari-mcp 2.7.9 → 2.7.11

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
@@ -538,7 +538,7 @@ Your support funds:
538
538
 
539
539
  ## Community
540
540
 
541
- **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.
542
542
 
543
543
  - [GitHub Discussions](https://github.com/achiya-automation/safari-mcp/discussions) — ask questions, share use cases
544
544
  - [Issues](https://github.com/achiya-automation/safari-mcp/issues) — bug reports and feature requests
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) {
@@ -953,6 +991,37 @@ 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
+
1012
+ server.tool(
1013
+ "safari_native_keyboard",
1014
+ "OS-level keyboard event via macOS CGEvent — sends a real keypress (with optional modifiers) to the Safari window WITHOUT activating Safari or stealing focus. Use when safari_press_key's JS path doesn't reach React trust-gated handlers (Discord ProseMirror Enter, Slack send, virtualized editors). Keys: enter, return, tab, escape, space, delete, backspace, up/down/left/right, home, end, pageup, pagedown, f1-f6, a-z, 0-9 and common punctuation. Modifiers: cmd, shift, alt, ctrl. Produces isTrusted:true events. Never activates Safari — runs entirely in the background.",
1015
+ {
1016
+ key: z.string().describe("Key name: enter, escape, tab, space, arrow keys, letters, digits, etc."),
1017
+ modifiers: z.array(z.string()).optional().default([]).describe("Modifier keys: cmd, shift, alt, ctrl"),
1018
+ },
1019
+ async (args) => {
1020
+ const result = await safari.nativeKeyboard(args);
1021
+ return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
1022
+ }
1023
+ );
1024
+
956
1025
  // ========== FORM INPUT ==========
957
1026
 
958
1027
  server.tool(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.7.9",
3
+ "version": "2.7.11",
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-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
@@ -324,13 +383,30 @@ while let line = readLine(strippingNewline: true) {
324
383
  continue
325
384
  }
326
385
 
327
- var errorDict: NSDictionary?
328
- let result = nsScript.executeAndReturnError(&errorDict)
386
+ // Execute on a background thread to avoid blocking stdin reading.
387
+ // Heavy pages (SourceForge, etc.) can cause executeAndReturnError() to block
388
+ // for 10-30+ seconds, preventing ALL subsequent commands from being read.
389
+ let semaphore = DispatchSemaphore(value: 0)
390
+ var scriptResult: NSAppleEventDescriptor?
391
+ var scriptError: NSDictionary?
392
+
393
+ DispatchQueue.global(qos: .userInitiated).async {
394
+ var errorDict: NSDictionary?
395
+ scriptResult = nsScript.executeAndReturnError(&errorDict)
396
+ scriptError = errorDict
397
+ semaphore.signal()
398
+ }
399
+
400
+ // Wait up to 30 seconds for the script to complete.
401
+ // If it times out, respond with error but don't block the loop forever.
402
+ let waitResult = semaphore.wait(timeout: .now() + 30.0)
329
403
 
330
- if let error = errorDict {
404
+ if waitResult == .timedOut {
405
+ respond(["error": "AppleScript execution timed out (30s)"])
406
+ } else if let error = scriptError {
331
407
  let msg = (error["NSAppleScriptErrorMessage"] as? String) ?? "AppleScript error"
332
408
  respond(["error": msg])
333
409
  } else {
334
- respond(["result": result.stringValue ?? ""])
410
+ respond(["result": scriptResult?.stringValue ?? ""])
335
411
  }
336
412
  }
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 }) {
@@ -1277,6 +1397,44 @@ const jsKeyMap = {
1277
1397
  f1: "F1", f2: "F2", f3: "F3", f4: "F4", f5: "F5", f6: "F6",
1278
1398
  };
1279
1399
 
1400
+ // macOS virtual key codes for CGEvent keyboard (used by _helperNativeKeyboard).
1401
+ // These are the HID-level codes that postToPid uses, NOT JS keyCode values.
1402
+ const macKeyCodeMap = {
1403
+ enter: 36, return: 36, "numpad-enter": 76,
1404
+ tab: 48, space: 49, delete: 51, backspace: 51, escape: 53,
1405
+ up: 126, "arrowup": 126, down: 125, "arrowdown": 125,
1406
+ left: 123, "arrowleft": 123, right: 124, "arrowright": 124,
1407
+ home: 115, end: 119, pageup: 116, pagedown: 121,
1408
+ f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97,
1409
+ a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9,
1410
+ b: 11, q: 12, w: 13, e: 14, r: 15, y: 16, t: 17,
1411
+ "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23,
1412
+ "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
1413
+ "]": 30, o: 31, u: 32, "[": 33, i: 34, p: 35,
1414
+ l: 37, j: 38, "'": 39, k: 40, ";": 41, "\\": 42,
1415
+ ",": 43, "/": 44, n: 45, m: 46, ".": 47, "`": 50,
1416
+ };
1417
+
1418
+ // Native keyboard via CGEvent — sends a single key (with optional modifiers)
1419
+ // to the Safari window WITHOUT activating Safari or moving the mouse.
1420
+ // This produces isTrusted:true events that bypass React trust checks (Discord ProseMirror,
1421
+ // Slack virtualized editors, etc.) without any focus stealing. Requires Safari window ID.
1422
+ export async function nativeKeyboard({ key, modifiers = [] }) {
1423
+ await ensureHelpers();
1424
+ if (!key) throw new Error("nativeKeyboard requires 'key'");
1425
+ const k = String(key).toLowerCase();
1426
+ const keyCode = macKeyCodeMap[k];
1427
+ if (keyCode === undefined) {
1428
+ throw new Error(`nativeKeyboard: unsupported key "${key}". Supported: ${Object.keys(macKeyCodeMap).join(", ")}`);
1429
+ }
1430
+ const geo = await _getSafariWindowGeometry();
1431
+ if (!geo.windowId) throw new Error("Cannot native-key without Safari window ID — would steal focus");
1432
+ const normalized = (modifiers || []).map(m => String(m).toLowerCase());
1433
+ await _helperNativeKeyboard(keyCode, normalized, geo.windowId);
1434
+ const modsLabel = normalized.length ? normalized.join("+") + "+" : "";
1435
+ return `Native key: ${modsLabel}${k} (CGEvent to window ${geo.windowId}, no focus steal)`;
1436
+ }
1437
+
1280
1438
  // System Events key codes — only used for paste_image, upload_file, save_pdf
1281
1439
  // (functions that truly require OS-level UI interaction)
1282
1440