safari-mcp 2.21.6 โ†’ 2.21.7

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
@@ -43,7 +43,7 @@ Native WebKit. ~60% less CPU. Background operation. 98 tools. One `npx` command.
43
43
 
44
44
  > ๐Ÿ“ฐ **Featured on freeCodeCamp:** [How to Connect Your AI Coding Agent to a Browser on macOS](https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/) ยท [HackerNoon: Reverse-Engineering React, Shadow DOM, and CSP](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
45
45
 
46
- > ๐ŸŽ **Apple shipped an official Safari MCP** (July 2026 โ€” Safari Technology Preview 247+ and the Safari 27 beta). It's built on `safaridriver` for isolated debugging sessions. safari-mcp drives the **real Safari you're already logged into** โ€” on the stable Safari that ships with macOS today, with 98 tools. See the full comparison below.
46
+ > ๐ŸŽ **Apple ships an official Safari MCP** โ€” `safaridriver --mcp`, first in Safari Technology Preview 247 (July 2026) and in stable Safari since 27.0. It drives an isolated automation session for debugging. safari-mcp drives the **real Safari you're already logged into** โ€” your cookies and sessions, in the background, with 98 tools. See the full comparison below.
47
47
 
48
48
  ---
49
49
 
@@ -81,7 +81,7 @@ Not solicited testimonials โ€” quotes lifted from the public issue tracker, each
81
81
  - macOS (any version with Safari)
82
82
  - Node.js 20+
83
83
  - Safari โ†’ Settings โ†’ Advanced โ†’ **Show features for web developers** โœ“
84
- - Safari โ†’ Develop โ†’ **Allow JavaScript from Apple Events** โœ“
84
+ - Safari โ†’ Settings โ†’ Developer โ†’ **Allow JavaScript from Apple Events** โœ“
85
85
 
86
86
  ### Install (one command)
87
87
 
@@ -631,23 +631,25 @@ Safari MCP runs locally on your Mac with minimal attack surface:
631
631
 
632
632
  ### vs Apple's Official Safari MCP (safaridriver)
633
633
 
634
- In July 2026 Apple shipped an **official** Safari MCP server built on `safaridriver` โ€” first in Safari Technology Preview 247, and now also in the Safari 27 beta. That's great validation for the category โ€” and it's built for a different job. Apple's server drives an **isolated WebDriver automation session** for debugging; safari-mcp drives the **real Safari you're already logged into**.
634
+ In July 2026 Apple shipped an **official** Safari MCP server built on `safaridriver` โ€” first in Safari Technology Preview 247, and in stable Safari since 27.0. That's great validation for the category โ€” and it's built for a different job. Apple's server drives an **isolated WebDriver automation session** for debugging; safari-mcp drives the **real Safari you're already logged into**.
635
635
 
636
- It has not reached a stable Safari release yet: on macOS 26.5.2 with Safari 26.5.2, `safaridriver --help` lists `--port`, `--bidi`, `--enable` and `--diagnose`, and no `--mcp` (verified 2026-07-23). Check your own machine with `safaridriver --help | grep mcp` before assuming either way.
636
+ On macOS 27.0 with Safari 27.0, `safaridriver --help` lists `--mcp` and its `tools/list` answers with 17 entries (verified 2026-09-15); Safari 26.5.2 had no `--mcp` (verified 2026-07-23). Check your own machine with `safaridriver --help | grep mcp`.
637
+
638
+ > **Running both?** Apple's setup guide registers its server under the name `safari-mcp` (`claude mcp add safari-mcp -- /usr/bin/safaridriver --mcp`) โ€” the same name as this package. Give one of them a different name, e.g. `claude mcp add safari-webdriver -- /usr/bin/safaridriver --mcp`, so your client doesn't overwrite one with the other or leave you guessing which server answered.
637
639
 
638
640
  | | ๐Ÿฆ safari-mcp *(this repo)* | Apple `safaridriver --mcp` |
639
641
  |---|:---:|:---:|
640
642
  | **Your real logins / cookies** | โœ… Your actual Safari | โš ๏ธ Isolated automation session โ€” no access to AutoFill or browsing activity |
641
- | **Runs on** | โœ… Stable Safari, every Mac | โš ๏ธ Safari Technology Preview 247+ or the Safari 27 beta โ€” not in stable Safari 26.5 |
643
+ | **Runs on** | โœ… Stable Safari, every Mac | โš ๏ธ Safari 27 or later, or Safari Technology Preview 247+ |
642
644
  | **Background (no focus steal)** | โœ… Yes | โŒ Dedicated window with a "controlled by automation" banner |
643
- | **Tools** | **98** | ~17 |
645
+ | **Tools** | **98** | 17 |
644
646
  | **Storage** (cookies, localStorage, IndexedDB) | โœ… 10 tools | โŒ |
645
647
  | **Network mocking + throttling** | โœ… Yes | โŒ Read-only network inspection |
646
648
  | **Device emulation** (iPhone, iPad) | โœ… Yes | โš ๏ธ Viewport + media type only |
647
- | **Setup** | `npx safari-mcp` | Enable "remote automation and external agents", then point your client at that build's `safaridriver --mcp` |
649
+ | **Setup** | `npx safari-mcp` | Enable "Allow remote automation and external agents" in Safari's Developer settings, then point your client at `safaridriver --mcp` |
648
650
  | **Official Apple support** | โŒ Community (MIT) | โœ… Apple, WebDriver-standard |
649
651
 
650
- > **When Apple's server is the right pick:** you specifically want a clean-room, WebDriver-standard session for compatibility debugging and you already run a preview or beta build. **For everything else โ€” daily automation on the browser you're already signed into, on the Safari that shipped with your Mac โ€” safari-mcp is built for exactly that.**
652
+ > **When Apple's server is the right pick:** you specifically want a clean-room, WebDriver-standard session for compatibility debugging on Safari 27 or later. **For everything else โ€” daily automation on the browser you're already signed into, in the background โ€” safari-mcp is built for exactly that.**
651
653
 
652
654
  ### Why Safari MCP and Not the Other Safari MCP Projects?
653
655
 
@@ -759,12 +761,12 @@ Alternatively, open `xcode/Safari MCP/Safari MCP.xcodeproj` directly in Xcode, s
759
761
 
760
762
  Then in Safari:
761
763
  1. Safari โ†’ Settings โ†’ Advanced โ†’ enable **Show features for web developers**
762
- 2. Safari โ†’ Develop โ†’ **Allow Unsigned Extensions** (required each Safari restart)
764
+ 2. Safari โ†’ Settings โ†’ Developer โ†’ **Allow unsigned extensions** (required each Safari restart)
763
765
  3. Safari โ†’ Settings โ†’ Extensions โ†’ enable **Safari MCP Bridge**
764
766
 
765
767
  The extension connects automatically to the first local bridge whose declared Safari profile matches its own. The default bridge ports are `9224`, `9228`, `9232`, and `9236`; a single-profile setup normally uses `9224`.
766
768
 
767
- > **Note:** "Allow Unsigned Extensions" resets every time Safari restarts. You'll need to re-enable it in the Develop menu after each restart. The extension itself stays installed.
769
+ > **Note:** "Allow unsigned extensions" resets every time Safari restarts. You'll need to re-enable it in Safari โ†’ Settings โ†’ Developer after each restart. The extension itself stays installed.
768
770
 
769
771
  **Toolbar icon status:**
770
772
  - **ON** โ€” connected to MCP server
@@ -779,16 +781,16 @@ Safari MCP needs these one-time permissions:
779
781
 
780
782
  | Permission | Where | Why |
781
783
  |-----------|-------|-----|
782
- | JavaScript from Apple Events | Safari โ†’ Develop menu | Required for `do JavaScript` |
784
+ | JavaScript from Apple Events | Safari โ†’ Settings โ†’ Developer | Required for `do JavaScript` |
783
785
  | Automation โ†’ Safari | System Settings โ†’ Privacy & Security โ†’ Automation | Required for all AppleScript-backed tools |
784
- | Screen Recording | System Settings โ†’ Privacy & Security โ†’ Screen Recording | Required for `safari_screenshot` |
785
- | Accessibility (safari-helper) | System Settings โ†’ Privacy & Security โ†’ Accessibility | Required for `safari_native_click`, `safari_native_keyboard`, `safari_native_hover` and `safari_save_pdf` |
786
+ | Screen Recording | System Settings โ†’ Privacy & Security โ†’ Screen & System Audio Recording | Required for `safari_screenshot` without the extension, and for `safari_save_pdf` |
787
+ | Accessibility (safari-helper) | System Settings โ†’ Privacy & Security โ†’ Accessibility (named **Device Control and Data Access** on macOS 27) | Required for `safari_native_click`, `safari_native_keyboard`, `safari_native_hover` and `safari_save_pdf` |
786
788
 
787
789
  ### Granting Accessibility to safari-helper (required for `safari_native_*`)
788
790
 
789
791
  The `safari_native_click`, `safari_native_keyboard` and `safari_native_hover` tools inject OS-level `CGEvent` events into Safari without stealing focus. macOS requires the underlying helper binary to be approved in **Accessibility** before those events can reach a non-frontmost window.
790
792
 
791
- 1. Open **System Settings โ†’ Privacy & Security โ†’ Accessibility**.
793
+ 1. Open **System Settings โ†’ Privacy & Security โ†’ Accessibility** (on macOS 27 the pane is named **Device Control and Data Access**).
792
794
  2. Click `+` (unlock with your password if needed).
793
795
  3. Navigate to the helper binary and add it:
794
796
  - npm global install: `$(npm root -g)/safari-mcp/safari-helper`
@@ -818,11 +820,12 @@ That call registers the Terminal app in the Automation database and then trigger
818
820
 
819
821
  | Issue | Fix |
820
822
  |-------|-----|
821
- | "AppleScript error" | Enable "Allow JavaScript from Apple Events" in Safari โ†’ Develop |
823
+ | "AppleScript error" | Enable "Allow JavaScript from Apple Events" in Safari โ†’ Settings โ†’ Developer |
822
824
  | "Not authorized to send Apple events to Safari" | Grant Automation โ†’ Safari to your IDE (see above) |
823
825
  | "Not authorized" after `npm update` | Updating changes the binary's cdhash โ€” macOS silently revokes Automation permission. Re-run the `osascript` one-liner above to re-grant it |
824
826
  | `safari_native_click` reports success but page doesn't react | Add `safari-helper` to **System Settings โ†’ Privacy & Security โ†’ Accessibility** (see [Granting Accessibility](#granting-accessibility-to-safari-helper-required-for-safari_native_) above). Confirm by attaching a `click` listener with `{capture:true}` in the page console โ€” without the grant, no `isTrusted: true` event fires |
825
- | Screenshots empty | Grant Screen Recording permission to Terminal/VS Code |
827
+ | Screenshots empty | Grant Screen & System Audio Recording to Terminal/VS Code โ€” or, when safari-mcp runs as a LaunchAgent, to the `node` binary itself (a Homebrew node upgrade changes its path, so re-grant after upgrading). `safari_doctor` names the exact binary |
828
+ | Extension missing after enabling "Allow unsigned extensions" (macOS 27) | Known macOS 27.0 issue (183044008): with the setting on, rebuild the extension app (see [Installing the Extension](#installing-the-extension)) and it appears |
826
829
  | Tab not found | Call `safari_list_tabs` to refresh tab indices |
827
830
  | Hebrew keyboard issues | All typing uses JS events โ€” immune to keyboard layout |
828
831
  | HTTPS blocked | `safari_navigate` auto-tries HTTPS first, falls back to HTTP |
@@ -1260,44 +1260,50 @@ async function handleCommand(type, payload) {
1260
1260
  // Strategy: try captureVisibleTab WITHOUT focusing the window first.
1261
1261
  // If it fails, fall back to AppleScript screencapture -l (which also doesn't steal focus).
1262
1262
  // NEVER use browser.windows.update({ focused: true }) โ€” it steals user's keyboard/mouse.
1263
- let captureWindowId = _profileWindowId || null;
1264
- // captureVisibleTab only sees the window's selected tab, so ours must be selected for
1265
- // the capture โ€” but the selection belongs to the user when they browse this window.
1266
- // Remember what they had selected and put it back (the AppleScript path already does
1267
- // this via _withTargetTabFronted; this path used to leave our tab in front).
1268
- let restoreTabId = null;
1269
- if (tabId) {
1263
+ return _withTabSelected(tabId, async (windowId) => {
1264
+ // Use JPEG with quality 50 to reduce size (~600KB PNG โ†’ ~60KB JPEG)
1270
1265
  try {
1271
- const tabInfo = await browser.tabs.get(tabId);
1272
- captureWindowId = tabInfo.windowId;
1273
- if (!tabInfo.active) {
1274
- const [selected] = await browser.tabs.query({ active: true, windowId: tabInfo.windowId });
1275
- if (selected && selected.id !== tabId) restoreTabId = selected.id;
1276
- // Only selects the tab โ€” does NOT bring the Safari window to the foreground
1277
- await browser.tabs.update(tabId, { active: true });
1278
- await new Promise(r => setTimeout(r, 150));
1279
- }
1280
- } catch (_) {}
1281
- }
1282
- // Use JPEG with quality 50 to reduce size (~600KB PNG โ†’ ~60KB JPEG)
1283
- try {
1284
- const dataUrl = await browser.tabs.captureVisibleTab(captureWindowId, {
1285
- format: "jpeg",
1286
- quality: 50,
1287
- });
1288
- return dataUrl.split(",")[1];
1289
- } catch (screenshotErr) {
1290
- // Permission lost or window not visible โ€” signal MCP to use AppleScript fallback
1291
- // AppleScript uses screencapture -l<windowId> which captures without stealing focus
1292
- const msg = screenshotErr.message || "";
1293
- if (msg.includes("permission") || msg.includes("screencapture") || msg.includes("Screen Recording") || msg.includes("visible")) {
1266
+ const dataUrl = await browser.tabs.captureVisibleTab(windowId, {
1267
+ format: "jpeg",
1268
+ quality: 50,
1269
+ });
1270
+ return dataUrl.split(",")[1];
1271
+ } catch (_) {
1272
+ // Permission lost or window not visible โ€” signal MCP to use AppleScript fallback
1273
+ // AppleScript uses screencapture -l<windowId> which captures without stealing focus
1294
1274
  return "__SCREENSHOT_PERMISSION_DENIED__";
1295
1275
  }
1296
- // Any other error also falls back โ€” better than stealing focus
1297
- return "__SCREENSHOT_PERMISSION_DENIED__";
1298
- } finally {
1299
- if (restoreTabId !== null) browser.tabs.update(restoreTabId, { active: true }).catch(() => {});
1300
- }
1276
+ });
1277
+ }
1278
+
1279
+ case "screenshot_element": {
1280
+ // A profile session refuses the AppleScript crop, so crop here: capture the tab as
1281
+ // "screenshot" does and cut out the element, measured while the tab is selected.
1282
+ let box = null;
1283
+ const dataUrl = await _withTabSelected(tabId, async (windowId) => {
1284
+ box = await execInTab(async (selector) => {
1285
+ const el = (window.__mcpDeepQuery || document.querySelector.bind(document))(selector);
1286
+ if (!el) return null;
1287
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1288
+ await new Promise((resolve) => setTimeout(resolve, 100));
1289
+ const r = el.getBoundingClientRect();
1290
+ let left = r.left, top = r.top;
1291
+ // A same-origin iframe match is measured in its frame's viewport: add the frame's
1292
+ // position and border to get the page viewport the capture shows.
1293
+ for (let view = el.ownerDocument.defaultView; view.frameElement; view = view.parent) {
1294
+ const frame = view.frameElement.getBoundingClientRect();
1295
+ left += frame.left + view.frameElement.clientLeft;
1296
+ top += frame.top + view.frameElement.clientTop;
1297
+ }
1298
+ return { left, top, width: r.width, height: r.height, viewportWidth: window.innerWidth };
1299
+ }, [payload.selector], tabId);
1300
+ if (!box) return null;
1301
+ return browser.tabs.captureVisibleTab(windowId, { format: "png" }).catch(() => null);
1302
+ });
1303
+ if (!box) return "Element not found: " + payload.selector;
1304
+ // Same signal as "screenshot": outside a profile the AppleScript crop can still try.
1305
+ if (!dataUrl) return "__SCREENSHOT_PERMISSION_DENIED__";
1306
+ return (await _cropCapture(dataUrl, box)) || "Element has no visible area: " + payload.selector;
1301
1307
  }
1302
1308
 
1303
1309
  // --- Click & Input ---
@@ -1437,11 +1443,11 @@ async function handleCommand(type, payload) {
1437
1443
  }
1438
1444
  el = exactBest || partialBest;
1439
1445
  }
1440
- } else if (x !== undefined && y !== undefined) {
1446
+ } else if (x != null && y != null) {
1441
1447
  el = document.elementFromPoint(x, y);
1442
1448
  }
1443
1449
 
1444
- if (!el) return "Element not found" + (ref ? " ref=" + ref : "") + (selector ? " selector=" + selector : "") + (text ? ' text="' + text + '"' : "") + (x !== undefined ? " x=" + x + " y=" + y : "");
1450
+ if (!el) return "Element not found" + (ref ? " ref=" + ref : "") + (selector ? " selector=" + selector : "") + (text ? ' text="' + text + '"' : "") + (x != null ? " x=" + x + " y=" + y : "");
1445
1451
 
1446
1452
  // --- Visibility check ---
1447
1453
  const cs = window.getComputedStyle(el);
@@ -1562,69 +1568,16 @@ async function handleCommand(type, payload) {
1562
1568
  return "Navigated to: " + href;
1563
1569
  }
1564
1570
 
1565
- // Fallback: if element not found in main frame, try all frames (cross-origin iframes)
1571
+ // Fallback: the content bridge runs in the top frame's ISOLATED world, so it misses
1572
+ // child documents and closed shadow roots. Probe every frame read-only, then click
1573
+ // once in the one frame (and the one world) that can reach the target.
1566
1574
  if (result && (result.startsWith("Element not found") || result === "No click target")) {
1567
- const iframeArgs = [payload.selector, payload.text, payload.ref];
1568
- const iframeResult = await execInFirstMatchingFrameMutating((selector, text, ref) => {
1569
- const deepQuery = (query, root = document) => {
1570
- const direct = root.querySelector(query);
1571
- if (direct) return direct;
1572
- for (const host of root.querySelectorAll("*")) {
1573
- if (!host.shadowRoot) continue;
1574
- const nested = deepQuery(query, host.shadowRoot);
1575
- if (nested) return nested;
1576
- }
1577
- return null;
1578
- };
1579
- if (ref) {
1580
- const safeRef = String(ref).replace(/["\\]/g, "\\$&");
1581
- return !!deepQuery('[data-mcp-ref="' + safeRef + '"]');
1582
- }
1583
- if (selector) return !!deepQuery(selector);
1584
- if (!text) return false;
1585
- const candidates = document.querySelectorAll("button, a, [role='button'], input[type='submit']");
1586
- for (let i = 0; i < candidates.length; i++) {
1587
- const value = (candidates[i].innerText || candidates[i].textContent || "").trim();
1588
- if (value && (value === text || value.includes(text) || text.includes(value))) return true;
1589
- }
1590
- return false;
1591
- }, iframeArgs, (selector, text, ref) => {
1592
- const deepQuery = (query, root = document) => {
1593
- const direct = root.querySelector(query);
1594
- if (direct) return direct;
1595
- for (const host of root.querySelectorAll("*")) {
1596
- if (!host.shadowRoot) continue;
1597
- const nested = deepQuery(query, host.shadowRoot);
1598
- if (nested) return nested;
1599
- }
1600
- return null;
1601
- };
1602
- let el = null;
1603
- if (ref) {
1604
- const safeRef = String(ref).replace(/["\\]/g, "\\$&");
1605
- el = deepQuery('[data-mcp-ref="' + safeRef + '"]');
1606
- } else if (selector) {
1607
- el = deepQuery(selector);
1608
- } else if (text) {
1609
- // Search interactive elements by text
1610
- const candidates = document.querySelectorAll("button, a, [role='button'], input[type='submit']");
1611
- for (let i = 0; i < candidates.length; i++) {
1612
- const t = (candidates[i].innerText || candidates[i].textContent || "").trim();
1613
- if (t === text) { el = candidates[i]; break; }
1614
- }
1615
- // Fuzzy: contains match
1616
- if (!el) {
1617
- for (let i = 0; i < candidates.length; i++) {
1618
- const t = (candidates[i].innerText || candidates[i].textContent || "").trim();
1619
- if (t && (t.includes(text) || text.includes(t))) { el = candidates[i]; break; }
1620
- }
1621
- }
1622
- }
1623
- if (!el) return null;
1624
- el.scrollIntoView({ block: "center", behavior: "instant" });
1625
- el.click();
1626
- return "Clicked (iframe): " + el.tagName + (el.textContent ? ' "' + el.textContent.trim().substring(0, 50) + '"' : "");
1627
- }, iframeArgs, tabId);
1575
+ const target = [payload.selector, payload.text, payload.ref];
1576
+ const iframeResult = await execInFirstMatchingFrameMutating(
1577
+ _clickFrameAction, [...target, "probe"],
1578
+ _clickFrameAction, [...target, "click"],
1579
+ tabId
1580
+ );
1628
1581
  if (iframeResult) return iframeResult;
1629
1582
  }
1630
1583
  return result;
@@ -1905,7 +1858,10 @@ async function handleCommand(type, payload) {
1905
1858
  const result = await execInTab((text, selector) => {
1906
1859
  if (selector) {
1907
1860
  const el = (window.__mcpDeepQuery || document.querySelector.bind(document))(selector);
1908
- if (!el) return "Element not found: " + selector;
1861
+ // __mcpDeepQuery also returns fields from same-origin iframes, but every strategy
1862
+ // below types through THIS document (execCommand, activeElement) โ€” into whichever
1863
+ // top-frame field last had focus. A miss sends the frame fallback to type there.
1864
+ if (!el || el.ownerDocument !== document) return "Element not found: " + selector;
1909
1865
  el.focus();
1910
1866
  }
1911
1867
 
@@ -2420,7 +2376,7 @@ async function handleCommand(type, payload) {
2420
2376
  const dq = window.__mcpDeepQuery || document.querySelector.bind(document);
2421
2377
  let el = null;
2422
2378
  if (selector) el = dq(selector);
2423
- else if (x !== undefined && y !== undefined) el = document.elementFromPoint(x, y);
2379
+ else if (x != null && y != null) el = document.elementFromPoint(x, y);
2424
2380
  if (!el) return "Element not found: " + (selector || "x=" + x + ",y=" + y);
2425
2381
  el.scrollIntoView({ block: "center" });
2426
2382
  const r = el.getBoundingClientRect();
@@ -2443,7 +2399,7 @@ async function handleCommand(type, payload) {
2443
2399
  const dq = window.__mcpDeepQuery || document.querySelector.bind(document);
2444
2400
  let el = null;
2445
2401
  if (selector) el = dq(selector);
2446
- else if (x !== undefined && y !== undefined) el = document.elementFromPoint(x, y);
2402
+ else if (x != null && y != null) el = document.elementFromPoint(x, y);
2447
2403
  if (!el) return "Element not found: " + (selector || "x=" + x + ",y=" + y);
2448
2404
  el.scrollIntoView({ block: "center" });
2449
2405
  const r = el.getBoundingClientRect();
@@ -3742,6 +3698,9 @@ async function sendContentCommand(tabId, type, payload, timeoutMs = 1500) {
3742
3698
  // injected into such a tab. Schedule a small, self-contained DOM action and
3743
3699
  // return BEFORE it runs: page-listener exceptions then cannot be misreported as
3744
3700
  // an executeScript failure, and a filled form survives an extension repair.
3701
+ // The target is resolved before returning, though: this lookup sees only the top
3702
+ // document, so a miss must reach the caller's frame / closed-shadow fallback
3703
+ // instead of reporting "Scheduled" for an action that can never find its element.
3745
3704
  const scheduled = await browser.scripting.executeScript({
3746
3705
  target: { tabId },
3747
3706
  world: "ISOLATED",
@@ -3765,10 +3724,10 @@ async function sendContentCommand(tabId, type, payload, timeoutMs = 1500) {
3765
3724
  }
3766
3725
  return null;
3767
3726
  };
3727
+ const el = find();
3728
+ if (!el) return { ok: false, result: "Element not found" };
3768
3729
  setTimeout(() => {
3769
3730
  try {
3770
- const el = find();
3771
- if (!el) return;
3772
3731
  if (commandType === "mcp-content-fill") {
3773
3732
  const value = String(commandPayload.value ?? "");
3774
3733
  el.focus();
@@ -3822,16 +3781,25 @@ const _INJECTION_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
3822
3781
  function _originKey(url) {
3823
3782
  try { return new URL(String(url || "")).origin; } catch { return ""; }
3824
3783
  }
3825
- function _withInjectionDeadline(promise, ms = MAIN_WORLD_INJECT_MS) {
3784
+ function _withInjectionDeadline(promise, ms = MAIN_WORLD_INJECT_MS, message = "MAIN world injection stalled") {
3826
3785
  let timer;
3827
3786
  return Promise.race([
3828
3787
  promise.finally(() => clearTimeout(timer)),
3829
3788
  new Promise((_, reject) => {
3830
- timer = setTimeout(() => reject(new Error("MAIN world injection stalled")), ms);
3789
+ timer = setTimeout(() => reject(new Error(message)), ms);
3831
3790
  }),
3832
3791
  ]);
3833
3792
  }
3834
3793
 
3794
+ // Safari's scripting.executeScript drops `undefined` entries from `args` and shifts every
3795
+ // later argument left: a ref-only click probe `(selector, text, ref)` received the ref as
3796
+ // its selector, and wait_for by text received its text as a selector. Every executor
3797
+ // sends args through here; injected functions test these values with truthiness or
3798
+ // `!= null`, so null carries the same meaning and keeps each argument in its position.
3799
+ function _scriptArgs(args) {
3800
+ return Array.from(args || [], (value) => (value === undefined ? null : value));
3801
+ }
3802
+
3835
3803
  async function execInTab(func, args = [], tabId = null) {
3836
3804
  const id = tabId || (await getActiveTab()).id;
3837
3805
  try {
@@ -3856,13 +3824,13 @@ async function execInTab(func, args = [], tabId = null) {
3856
3824
  let results;
3857
3825
  try {
3858
3826
  results = await _withInjectionDeadline(
3859
- browser.scripting.executeScript({ target: { tabId: id }, world: "MAIN", func, args })
3827
+ browser.scripting.executeScript({ target: { tabId: id }, world: "MAIN", func, args: _scriptArgs(args) })
3860
3828
  );
3861
3829
  } catch (mainErr) {
3862
3830
  if (!/injection stalled/.test(mainErr?.message || "")) throw mainErr;
3863
3831
  console.warn("Safari MCP: MAIN world stalled, retrying ISOLATED on tabId=" + id);
3864
3832
  results = await _withInjectionDeadline(
3865
- browser.scripting.executeScript({ target: { tabId: id }, world: "ISOLATED", func, args })
3833
+ browser.scripting.executeScript({ target: { tabId: id }, world: "ISOLATED", func, args: _scriptArgs(args) })
3866
3834
  );
3867
3835
  // If ISOLATED stalls too the page blocks injection outright, and there is nothing
3868
3836
  // generic left to try โ€” execInTab relays a FUNCTION, which the content bridge
@@ -3892,7 +3860,7 @@ async function execInTabIsolated(func, args = [], tabId = null) {
3892
3860
  target: { tabId: id },
3893
3861
  world: "ISOLATED",
3894
3862
  func,
3895
- args,
3863
+ args: _scriptArgs(args),
3896
3864
  });
3897
3865
  try {
3898
3866
  let results;
@@ -3932,7 +3900,7 @@ async function _executeAllFrames(func, args = [], tabId = null) {
3932
3900
  target: { tabId: id, allFrames: true },
3933
3901
  world,
3934
3902
  func,
3935
- args,
3903
+ args: _scriptArgs(args),
3936
3904
  }));
3937
3905
 
3938
3906
  try {
@@ -3949,12 +3917,14 @@ async function _executeAllFrames(func, args = [], tabId = null) {
3949
3917
 
3950
3918
  // Mutating fallbacks must never broadcast an action to every frame or retry it after
3951
3919
  // an ambiguous executeScript rejection. First locate one frame with a read-only probe,
3952
- // then dispatch once to that concrete frame in ISOLATED world. If the one dispatch has
3953
- // an unknown outcome, fail terminally so a caller can verify state before retrying.
3920
+ // then dispatch once to that concrete frame in ISOLATED world โ€” or in MAIN when the probe
3921
+ // answers "MAIN", because its match sits in a closed shadow root ISOLATED cannot reach.
3922
+ // If the one dispatch has an unknown outcome, fail terminally so a caller can verify
3923
+ // state before retrying.
3954
3924
  async function execInFirstMatchingFrameMutating(matchFunc, matchArgs, func, args, tabId = null) {
3955
3925
  const id = tabId || (await getActiveTab()).id;
3956
3926
  const matches = await _executeAllFrames(matchFunc, matchArgs, id);
3957
- const match = matches.find((entry) => !entry?.error && entry?.result === true);
3927
+ const match = matches.find((entry) => !entry?.error && (entry?.result === true || entry?.result === "MAIN"));
3958
3928
  if (!match) return null;
3959
3929
  if (!Number.isInteger(match.frameId)) {
3960
3930
  throw new Error("Matched frame has no stable frameId; refusing mutating iframe fallback");
@@ -3963,9 +3933,9 @@ async function execInFirstMatchingFrameMutating(matchFunc, matchArgs, func, args
3963
3933
  try {
3964
3934
  const results = await _withInjectionDeadline(browser.scripting.executeScript({
3965
3935
  target: { tabId: id, frameIds: [match.frameId] },
3966
- world: "ISOLATED",
3936
+ world: match.result === "MAIN" ? "MAIN" : "ISOLATED",
3967
3937
  func,
3968
- args,
3938
+ args: _scriptArgs(args),
3969
3939
  }));
3970
3940
  const first = results[0];
3971
3941
  if (first?.error) throw new Error(first.error);
@@ -3975,6 +3945,53 @@ async function execInFirstMatchingFrameMutating(matchFunc, matchArgs, func, args
3975
3945
  }
3976
3946
  }
3977
3947
 
3948
+ // Click fallback for targets the top-frame content bridge cannot see: child documents and
3949
+ // closed shadow roots. Safari serializes it into each frame, so it stays self-contained.
3950
+ // Probe mode is read-only and names the world that can reach the match โ€” a closed shadow
3951
+ // root is visible only to MAIN, through the getter content.js installs at document_start.
3952
+ function _clickFrameAction(selector, text, ref, mode) {
3953
+ const getShadowRoot = window.__mcpGetShadowRoot || ((host) => host.shadowRoot);
3954
+ const roots = [];
3955
+ const collect = (root) => {
3956
+ roots.push(root);
3957
+ for (const host of root.querySelectorAll("*")) {
3958
+ const shadow = getShadowRoot(host);
3959
+ if (shadow) collect(shadow);
3960
+ }
3961
+ };
3962
+ collect(document);
3963
+
3964
+ let el = null;
3965
+ const query = ref ? '[data-mcp-ref="' + String(ref).replace(/["\\]/g, "\\$&") + '"]' : selector;
3966
+ if (query) {
3967
+ for (const root of roots) {
3968
+ el = root.querySelector(query);
3969
+ if (el) break;
3970
+ }
3971
+ } else if (text) {
3972
+ const label = (candidate) => (candidate.innerText || candidate.textContent || "").trim();
3973
+ const candidates = roots.flatMap((root) => [...root.querySelectorAll("button, a, [role='button'], input[type='submit']")]);
3974
+ el = candidates.find((candidate) => label(candidate) === text) ||
3975
+ candidates.find((candidate) => {
3976
+ const value = label(candidate);
3977
+ return value && (value.includes(text) || text.includes(value));
3978
+ });
3979
+ }
3980
+
3981
+ if (mode === "probe") {
3982
+ if (!el) return false;
3983
+ for (let root = el.getRootNode(); root instanceof ShadowRoot; root = root.host.getRootNode()) {
3984
+ if (root.mode === "closed") return "MAIN";
3985
+ }
3986
+ return true;
3987
+ }
3988
+ if (!el) return null;
3989
+ el.scrollIntoView({ block: "center", behavior: "instant" });
3990
+ el.click();
3991
+ return "Clicked" + (window === top ? "" : " (iframe)") + ": " + el.tagName +
3992
+ (el.textContent ? ' "' + el.textContent.trim().substring(0, 50) + '"' : "");
3993
+ }
3994
+
3978
3995
  // Shared exact-frame probe and MAIN-world one-shot popup capture. It stays
3979
3996
  // self-contained because Safari serializes it into each frame; closure references
3980
3997
  // are unavailable there. Probe mode is read-only. Capture mode dispatches one click,
@@ -4187,7 +4204,7 @@ async function execInExactMatchingFrameMainOnce(matchFunc, matchArgs, func, args
4187
4204
  target: { tabId: id, frameIds: [matches[0].frameId] },
4188
4205
  world: "MAIN",
4189
4206
  func,
4190
- args,
4207
+ args: _scriptArgs(args),
4191
4208
  }));
4192
4209
  const first = results[0];
4193
4210
  if (first?.error) throw new Error(first.error);
@@ -4376,6 +4393,60 @@ async function waitForTabLoad(tabId, timeout = 30000) {
4376
4393
  });
4377
4394
  }
4378
4395
 
4396
+ // captureVisibleTab only sees the window's selected tab, so ours must be selected for
4397
+ // the capture โ€” but the selection belongs to the user when they browse this window.
4398
+ // Remember what they had selected and put it back (the AppleScript path already does
4399
+ // this via _withTargetTabFronted; the extension path used to leave our tab in front).
4400
+ // `capture(windowId)` runs while the tab is selected.
4401
+ async function _withTabSelected(tabId, capture) {
4402
+ let windowId = _profileWindowId || null;
4403
+ let restoreTabId = null;
4404
+ if (tabId) {
4405
+ try {
4406
+ const tabInfo = await browser.tabs.get(tabId);
4407
+ windowId = tabInfo.windowId;
4408
+ if (!tabInfo.active) {
4409
+ const [selected] = await browser.tabs.query({ active: true, windowId: tabInfo.windowId });
4410
+ if (selected && selected.id !== tabId) restoreTabId = selected.id;
4411
+ // Only selects the tab โ€” does NOT bring the Safari window to the foreground
4412
+ await browser.tabs.update(tabId, { active: true });
4413
+ await new Promise(r => setTimeout(r, 150));
4414
+ }
4415
+ } catch (_) {}
4416
+ }
4417
+ try {
4418
+ return await capture(windowId);
4419
+ } finally {
4420
+ if (restoreTabId !== null) browser.tabs.update(restoreTabId, { active: true }).catch(() => {});
4421
+ }
4422
+ }
4423
+
4424
+ // Cuts a viewport box (CSS pixels) out of a captureVisibleTab data URL, clipped to what
4425
+ // the capture shows. Returns JPEG base64, or null when nothing of the box is on screen.
4426
+ async function _cropCapture(dataUrl, box) {
4427
+ // Not through an <img>: in Safari's extension background page it never finished loading
4428
+ // the capture, and the command hung until the worker's 330s guard with every later
4429
+ // command queued behind it. Decode the bytes directly, and bound the decode so a stall
4430
+ // fails this command instead of the worker.
4431
+ const bytes = Uint8Array.from(atob(dataUrl.slice(dataUrl.indexOf(",") + 1)), (c) => c.charCodeAt(0));
4432
+ const image = await _withInjectionDeadline(
4433
+ createImageBitmap(new Blob([bytes])), 10000, "screenshot_element: decoding the tab capture stalled"
4434
+ );
4435
+ // Scale from the capture itself rather than devicePixelRatio, so the crop follows the
4436
+ // resolution Safari actually rendered.
4437
+ const scale = image.width / box.viewportWidth;
4438
+ const left = Math.max(0, Math.round(box.left * scale));
4439
+ const top = Math.max(0, Math.round(box.top * scale));
4440
+ const width = Math.min(image.width, Math.round((box.left + box.width) * scale)) - left;
4441
+ const height = Math.min(image.height, Math.round((box.top + box.height) * scale)) - top;
4442
+ if (width <= 0 || height <= 0) return null;
4443
+ const canvas = document.createElement("canvas");
4444
+ canvas.width = width;
4445
+ canvas.height = height;
4446
+ canvas.getContext("2d").drawImage(image, left, top, width, height, 0, 0, width, height);
4447
+ return canvas.toDataURL("image/jpeg", 0.8).split(",")[1];
4448
+ }
4449
+
4379
4450
  function sleep(ms) {
4380
4451
  return new Promise(r => setTimeout(r, ms));
4381
4452
  }
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Safari MCP Bridge",
4
4
  "description": "Connects AI agents to Safari โ€” fast JS execution, screenshots, clicks with your real cookies/logins",
5
- "version": "2.10.12",
5
+ "version": "2.10.15",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/glama.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://glama.ai/mcp/schemas/server.json",
3
3
  "name": "Safari MCP Server",
4
- "description": "Native Safari browser automation for AI agents. 97 tools via AppleScript โ€” zero overhead, keeps logins, runs silently in background.",
4
+ "description": "Native Safari browser automation for AI agents. 98 tools via AppleScript โ€” zero overhead, keeps logins, runs silently in background.",
5
5
  "repository": "https://github.com/achiya-automation/safari-mcp",
6
6
  "homepage": "https://github.com/achiya-automation/safari-mcp",
7
7
  "author": "achiya-automation",
package/index.js CHANGED
@@ -2608,16 +2608,14 @@ server.tool(
2608
2608
 
2609
2609
  server.tool(
2610
2610
  "safari_screenshot_element",
2611
- "Take a screenshot of a specific element (by CSS selector). Returns base64 PNG image.",
2611
+ "Take a screenshot of a specific element (by CSS selector). Returns a base64 image.",
2612
2612
  { selector: z.string().describe("CSS selector of the element to capture"), receipt: z.string().optional().describe("Tab receipt from safari_new_tab โ€” pins this call to that tab (survives reconnects/subagents)"), },
2613
2613
  async ({ selector, receipt }) => {
2614
- const base64 = await extensionOrFallback(
2614
+ // The extension crops to JPEG, the AppleScript path to PNG; a miss comes back as text.
2615
+ return imageResult(await extensionOrFallback(
2615
2616
  "screenshot_element", { selector, ..._explicitReceipt({ receipt }) },
2616
2617
  () => safari.screenshotElement({ selector })
2617
- );
2618
- return {
2619
- content: [{ type: "image", data: base64, mimeType: "image/jpeg" }],
2620
- };
2618
+ ));
2621
2619
  }
2622
2620
  );
2623
2621
 
@@ -3168,13 +3166,12 @@ server.tool(
3168
3166
 
3169
3167
  server.tool(
3170
3168
  "safari_emulate",
3171
- "Emulate a mobile device by resizing window and setting user agent. Devices: iphone-14, iphone-14-pro-max, ipad, ipad-pro, pixel-7, galaxy-s24. Or use custom width/height.",
3169
+ `Emulate a mobile device: sizes the Safari window to the device's CSS viewport and overrides navigator.userAgent/platform/maxTouchPoints (iOS/iPadOS 27 values) in the current page โ€” JavaScript only: the HTTP User-Agent header is unchanged and a reload or navigation clears it. Devices: ${Object.keys(safari.EMULATION_DEVICES).join(", ")}. Or pass width/height (and userAgent).`,
3172
3170
  {
3173
- device: z.string().optional().describe("Device name: iphone-14, ipad, pixel-7, galaxy-s24, etc."),
3171
+ device: z.string().optional().describe("Device name from the list in the description, e.g. iphone-18-pro, iphone-air, ipad-pro-11"),
3174
3172
  width: z.coerce.number().optional().describe("Custom viewport width"),
3175
3173
  height: z.coerce.number().optional().describe("Custom viewport height"),
3176
3174
  userAgent: z.string().optional().describe("Custom user agent string"),
3177
- scale: z.coerce.number().optional().describe("Initial scale (default: 1)"),
3178
3175
  },
3179
3176
  async (args) => {
3180
3177
  _assertTabOwnership("emulate");
@@ -3185,7 +3182,7 @@ server.tool(
3185
3182
 
3186
3183
  server.tool(
3187
3184
  "safari_reset_emulation",
3188
- "Reset device emulation back to desktop mode",
3185
+ "Reset device emulation: restores the window size from before safari_emulate and removes its navigator overrides (no reload)",
3189
3186
  {},
3190
3187
  async () => {
3191
3188
  _assertTabOwnership("reset_emulation");
@@ -32,7 +32,13 @@ export const VIEWPORT_SCRIPT = `(function(){
32
32
  return JSON.stringify({viewport: content, attrs: attrs, ok: issues.length===0, errors: errors, warnings: warnings, issues: issues});
33
33
  })()`;
34
34
 
35
+ // Safari 27 stopped listing constructable (adopted) style sheets in document.styleSheets, per
36
+ // the CSSOM spec (Safari 27.0 release notes, 174583340). Stylesheet scans append them
37
+ // explicitly, skipping any an older engine still lists, so they see the same rules on both.
38
+ export const ALL_SHEETS_FN = `function allSheets(){var out=[].slice.call(document.styleSheets);var ad=document.adoptedStyleSheets||[];for(var a=0;a<ad.length;a++){if(out.indexOf(ad[a])===-1)out.push(ad[a]);}return out;}`;
39
+
35
40
  export const SAFE_AREA_SCRIPT = `(function(){
41
+ ${ALL_SHEETS_FN}
36
42
  if (!document.body) return JSON.stringify({error:'No document.body yet'});
37
43
  var probe = document.createElement('div');
38
44
  probe.style.cssText = 'position:fixed;top:env(safe-area-inset-top,0px);right:env(safe-area-inset-right,0px);bottom:env(safe-area-inset-bottom,0px);left:env(safe-area-inset-left,0px);pointer-events:none;visibility:hidden;';
@@ -44,7 +50,7 @@ export const SAFE_AREA_SCRIPT = `(function(){
44
50
  var viewportFitCover = meta ? ((meta.getAttribute('content')||'').indexOf('viewport-fit=cover') !== -1) : false;
45
51
  var usedInCSS = false;
46
52
  try {
47
- var sheets = document.styleSheets;
53
+ var sheets = allSheets();
48
54
  for (var i=0; i<sheets.length; i++) {
49
55
  try {
50
56
  var rules = sheets[i].cssRules;
@@ -68,17 +74,18 @@ export const PWA_SCRIPT = `(function(){
68
74
  var manifest = getLink('manifest');
69
75
  var has180 = touchIcons.some(function(i){return i.sizes==='180x180';});
70
76
  var checks = [];
71
- checks.push({pass: capable==='yes', label:'apple-mobile-web-app-capable', detail: capable==='yes' ? 'standalone mode enabled' : 'not set to yes โ€” opens in Safari, not standalone'});
77
+ checks.push({pass: capable==='yes' || manifest!==null, label:'standalone launch (apple-mobile-web-app-capable or manifest)', detail: capable==='yes' ? 'apple-mobile-web-app-capable=yes' : (manifest!==null ? 'manifest linked โ€” its display member sets standalone' : 'neither set โ€” iOS/iPadOS 26+ still open any Home Screen site as a web app by default; iOS 18 and older open it in Safari')});
72
78
  checks.push({pass: touchIcons.length>0, label:'apple-touch-icon', detail: touchIcons.length>0 ? (touchIcons.length+' icon(s): '+touchIcons.map(function(i){return i.sizes;}).join(', ')+(has180?'':' (missing 180x180 for iPhone)')) : 'none โ€” iOS uses a screenshot as the icon'});
73
- checks.push({pass: themeColor!==null, label:'theme-color', detail: themeColor!==null ? ('set to '+themeColor) : 'not set โ€” Safari 15+ tints the tab bar with it'});
79
+ checks.push({pass: themeColor!==null, label:'theme-color', detail: (themeColor!==null ? ('set to '+themeColor) : 'not set') + ' โ€” Chrome and Android color their UI with it; Safari 26+ ignores it and tints its bars from the page background'});
74
80
  checks.push({pass: statusBar!==null, label:'apple-mobile-web-app-status-bar-style', detail: statusBar!==null ? ('set to '+statusBar) : 'not set โ€” defaults to black-on-white'});
75
- checks.push({pass: manifest!==null, label:'web app manifest', detail: manifest!==null ? ('found: '+manifest) : 'no manifest link โ€” required for PWA install prompts'});
81
+ checks.push({pass: manifest!==null, label:'web app manifest', detail: manifest!==null ? ('found: '+manifest) : 'no manifest link โ€” the Home Screen web app gets no name, id, scope or display mode of its own (Safari has no install prompt to trigger)'});
76
82
  checks.push({pass: splash>0, label:'apple-touch-startup-image', detail: splash>0 ? (splash+' splash screen(s)') : 'none โ€” white screen while the app loads'});
77
83
  var passed = checks.filter(function(c){return c.pass;}).length;
78
84
  return JSON.stringify({passed: passed, total: checks.length, checks: checks});
79
85
  })()`;
80
86
 
81
87
  export const WEBKIT_COMPAT_SCRIPT = `(function(){
88
+ ${ALL_SHEETS_FN}
82
89
  var seen = {};
83
90
  function collectFromStyle(style){
84
91
  for (var k=0; k<style.length; k++){
@@ -96,7 +103,7 @@ export const WEBKIT_COMPAT_SCRIPT = `(function(){
96
103
  if (rule.cssRules){ try { processRules(rule.cssRules); } catch(e){} }
97
104
  }
98
105
  }
99
- var sheets = document.styleSheets;
106
+ var sheets = allSheets();
100
107
  for (var i=0; i<sheets.length; i++){ try { processRules(sheets[i].cssRules); } catch(e){} }
101
108
  var inline = document.querySelectorAll('[style]');
102
109
  for (var n=0; n<inline.length; n++) collectFromStyle(inline[n].style);
package/mcp-helpers.js CHANGED
@@ -15,11 +15,15 @@ if (window.__mcpVersion !== 6) {
15
15
  window.mcpCollectRoots = function() {
16
16
  if (!window.__mcpRootsDirty && window.__mcpCachedRoots) return window.__mcpCachedRoots;
17
17
  var roots = [];
18
+ // Closed roots come from the getter the extension's content.js installs at
19
+ // document_start; without it mcpFindRef fell back to coordinates and returned the host.
20
+ var getShadowRoot = window.__mcpGetShadowRoot || function(el) { return el.shadowRoot; };
18
21
  function collect(root) {
19
22
  roots.push(root);
20
23
  var all = root.querySelectorAll('*');
21
24
  for (var i = 0; i < all.length; i++) {
22
- if (all[i].shadowRoot) collect(all[i].shadowRoot);
25
+ var shadow = getShadowRoot(all[i]);
26
+ if (shadow) collect(shadow);
23
27
  }
24
28
  }
25
29
  collect(document);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.21.6",
3
+ "version": "2.21.7",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
- "description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
5
+ "description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
7
7
  "main": "index.js",
8
8
  "bin": {
package/response.js CHANGED
@@ -18,10 +18,16 @@ export const jsonResult = (r) => ({
18
18
  content: [{ type: "text", text: JSON.stringify(r, null, 2) }],
19
19
  });
20
20
 
21
- /** Image response (base64 + mime type). */
22
- export const imageResult = (data, mimeType = "image/jpeg") => ({
23
- content: [{ type: "image", data, mimeType }],
24
- });
21
+ /**
22
+ * Image response for base64 JPEG or PNG, labelled by the data's own magic bytes. Anything
23
+ * else is a message ("Element not found: โ€ฆ"): sent as image bytes the client cannot decode
24
+ * it and the reason is lost, so it is raised as the tool's error instead.
25
+ */
26
+ export const imageResult = (data) => {
27
+ const mimeType = /^\/9j\//.test(data) ? "image/jpeg" : /^iVBORw0KGgo/.test(data) ? "image/png" : null;
28
+ if (!mimeType) throw new Error(String(data || "no image data returned"));
29
+ return { content: [{ type: "image", data, mimeType }] };
30
+ };
25
31
 
26
32
  /** Error response โ€” sets `isError` so the MCP client renders it as a failure. */
27
33
  export const errorResult = (msg) => ({
package/safari.js CHANGED
@@ -11,7 +11,7 @@ import { readFile, writeFile, unlink, appendFile, mkdir } from "node:fs/promises
11
11
  import { readFileSync, realpathSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { randomUUID } from "node:crypto";
14
- import { VIEWPORT_SCRIPT, SAFE_AREA_SCRIPT, PWA_SCRIPT, WEBKIT_COMPAT_SCRIPT } from "./injected-validators.js";
14
+ import { VIEWPORT_SCRIPT, SAFE_AREA_SCRIPT, PWA_SCRIPT, WEBKIT_COMPAT_SCRIPT, ALL_SHEETS_FN } from "./injected-validators.js";
15
15
  import { escJsSingleQuote, escAppleScriptString } from "./injected-escape.js";
16
16
  import { currentSessionId } from "./session-context.js";
17
17
  import { allowUserTabs } from "./ownership-state.js";
@@ -3595,7 +3595,7 @@ async function _screenshotFronted({ fullPage }) {
3595
3595
  }
3596
3596
 
3597
3597
  // Final fallback: throw with clear message for the retry logic in index.js
3598
- throw new Error("screencapture failed โ€” Screen Recording permission may have been lost. Grant permission in System Settings โ†’ Privacy & Security โ†’ Screen Recording, then restart Safari.");
3598
+ throw new Error("screencapture failed โ€” Screen Recording permission may have been lost. Grant permission in System Settings โ†’ Privacy & Security โ†’ Screen & System Audio Recording, then restart Safari.");
3599
3599
  } finally {
3600
3600
  await unlink(tmpFile).catch(() => {});
3601
3601
  }
@@ -4600,70 +4600,118 @@ export async function pasteImageFromFile({ filePath }) {
4600
4600
 
4601
4601
  // ========== EMULATE (VIEWPORT) ==========
4602
4602
 
4603
- export async function emulate({ device, width, height, userAgent, scale = 1 }) {
4604
- await refreshTargetWindow();
4605
- const devices = {
4606
- "iphone-14": { width: 390, height: 844, ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
4607
- "iphone-14-pro-max": { width: 430, height: 932, ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
4608
- "ipad": { width: 820, height: 1180, ua: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
4609
- "ipad-pro": { width: 1024, height: 1366, ua: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" },
4610
- "pixel-7": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
4611
- "galaxy-s24": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
4612
- };
4613
-
4614
- const d = device ? devices[device.toLowerCase()] : null;
4615
- const w = d ? d.width : (width || 375);
4616
- const h = d ? d.height : (height || 812);
4617
- const ua = d ? d.ua : (userAgent || "");
4618
-
4619
- // Resize Safari window to match device
4620
- await osascript(
4621
- `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${w}, ${h + 100}}`
4622
- );
4603
+ // Captured from Mobile Safari on the iOS/iPadOS 27.0 simulator (Xcode 27, 2026-09-15). Since
4604
+ // iOS 26 Safari freezes the OS token (18_6 at 26.0, 18_7 from 26.2) and only Version/ moves.
4605
+ // iPadOS requests desktop sites, so an iPad sends the Mac UA and platform "MacIntel"; pages tell
4606
+ // it apart by navigator.maxTouchPoints, which the override sets too.
4607
+ const UA_IOS_27 = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1";
4608
+ const UA_IPADOS_27 = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Safari/605.1.15";
4609
+ const _iphone = (width, height) => ({ width, height, ua: UA_IOS_27, platform: "iPhone", touchPoints: 5 });
4610
+ const _ipad = (width, height) => ({ width, height, ua: UA_IPADOS_27, touchPoints: 5 });
4611
+
4612
+ // Portrait CSS viewport (screen points). The iphone-14* / ipad / ipad-pro names predate iOS 27
4613
+ // and stay so existing callers keep working.
4614
+ export const EMULATION_DEVICES = {
4615
+ "iphone-18-pro": _iphone(402, 874),
4616
+ "iphone-18-pro-max": _iphone(440, 956),
4617
+ "iphone-air": _iphone(420, 912),
4618
+ "iphone-17": _iphone(402, 874),
4619
+ "iphone-17e": _iphone(390, 844),
4620
+ "iphone-16": _iphone(393, 852),
4621
+ "iphone-14": _iphone(390, 844),
4622
+ "iphone-14-pro-max": _iphone(430, 932),
4623
+ "ipad": _ipad(820, 1180),
4624
+ "ipad-air-11": _ipad(820, 1180),
4625
+ "ipad-air-13": _ipad(1024, 1366),
4626
+ "ipad-pro-11": _ipad(834, 1210),
4627
+ "ipad-pro-13": _ipad(1032, 1376),
4628
+ "ipad-pro": _ipad(1024, 1366),
4629
+ "pixel-7": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
4630
+ "galaxy-s24": { width: 412, height: 915, ua: "Mozilla/5.0 (Linux; Android 14; SM-S921B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" },
4631
+ };
4623
4632
 
4624
- // Override viewport meta and user agent if specified
4625
- if (ua) {
4626
- await runJS(
4627
- `Object.defineProperty(navigator,'userAgent',{get:function(){return '${ua.replace(/'/g, "\\'")}'},configurable:true})`
4628
- );
4633
+ // Pure. An unknown name used to fall through to a bare 375ร—812 window with no UA while the
4634
+ // result still echoed the requested device โ€” refuse it instead.
4635
+ export function resolveEmulation({ device, width, height, userAgent } = {}) {
4636
+ if (device) {
4637
+ const name = String(device).trim().toLowerCase();
4638
+ if (!Object.hasOwn(EMULATION_DEVICES, name)) {
4639
+ throw new Error(`Unknown device "${device}". Available: ${Object.keys(EMULATION_DEVICES).join(", ")} โ€” or pass width/height (and userAgent).`);
4640
+ }
4641
+ return { name, ...EMULATION_DEVICES[name] };
4629
4642
  }
4643
+ const size = (v, fallback) => (Number(v) > 0 ? Math.round(Number(v)) : fallback);
4644
+ return { name: "custom", width: size(width, 375), height: size(height, 812), ua: userAgent || "" };
4645
+ }
4630
4646
 
4631
- // Set viewport meta tag
4632
- await runJS(
4633
- `(function(){var m=document.querySelector('meta[name=viewport]');if(!m){m=document.createElement('meta');m.name='viewport';document.head.appendChild(m);}m.content='width=${w},initial-scale=${scale}';})()`
4634
- );
4647
+ // Pure. The getters carry a marker so resetEmulation() removes only what emulate() added.
4648
+ export function buildNavigatorOverrideJS({ ua, platform, touchPoints } = {}) {
4649
+ const props = [];
4650
+ if (ua) props.push(`['userAgent','${escJsSingleQuote(ua)}']`);
4651
+ if (platform) props.push(`['platform','${escJsSingleQuote(platform)}']`);
4652
+ if (touchPoints != null) props.push(`['maxTouchPoints',${Number(touchPoints)}]`);
4653
+ if (!props.length) return "";
4654
+ return `(function(){[${props.join(",")}].forEach(function(p){var g=function(){return p[1]};g.__mcpEmulated=true;Object.defineProperty(navigator,p[0],{get:g,configurable:true})});return navigator.userAgent})()`;
4655
+ }
4635
4656
 
4636
- // Reload to apply changes, then wait for load โ€” polled from Node (`do JavaScript`
4637
- // can't await an in-page loop).
4638
- const navIndex = _st().activeTabIndex;
4639
- await runJS("location.reload()", { tabIndex: navIndex });
4640
- await new Promise(r => setTimeout(r, 200));
4641
- await _pollReadyAndRead(navIndex);
4642
-
4643
- return JSON.stringify({
4644
- device: device || "custom",
4645
- width: w,
4646
- height: h,
4647
- userAgent: ua ? ua.substring(0, 60) + "..." : "(default)",
4657
+ export const RESET_NAVIGATOR_JS = "(function(){['userAgent','platform','maxTouchPoints'].forEach(function(k){var d=Object.getOwnPropertyDescriptor(navigator,k);if(d&&d.get&&d.get.__mcpEmulated)delete navigator[k]});return navigator.userAgent})()";
4658
+
4659
+ export async function emulate(args) {
4660
+ const target = resolveEmulation(args);
4661
+ await refreshTargetWindow();
4662
+ const winRef = getTargetWindowRef();
4663
+ const st = _st();
4664
+ // Remember the user's window once, so reset_emulation puts it back instead of forcing 1440ร—900.
4665
+ // Comma-joined explicitly: the helper daemon returns "" for an AppleScript list.
4666
+ if (!st.preEmulationBounds) {
4667
+ const b = String(await osascriptFast(
4668
+ `tell application "Safari"\n set b to bounds of ${winRef}\n return (item 1 of b as text) & "," & (item 2 of b as text) & "," & (item 3 of b as text) & "," & (item 4 of b as text)\nend tell`
4669
+ ).catch(() => "")).trim();
4670
+ if (/^-?\d+,-?\d+,-?\d+,-?\d+$/.test(b)) st.preEmulationBounds = b;
4671
+ }
4672
+ // Size from the measured browser chrome, so the page viewport โ€” not the window โ€” matches.
4673
+ let chrome = 90;
4674
+ try {
4675
+ const c = Number(await runJS("(window.outerHeight - window.innerHeight) + ''"));
4676
+ if (Number.isFinite(c) && c >= 50 && c <= 200) chrome = c;
4677
+ } catch (_e) { /* keep the Safari 26/27 default */ }
4678
+ await osascriptFast(`tell application "Safari" to set bounds of ${winRef} to {0, 0, ${target.width}, ${target.height + chrome}}`);
4679
+
4680
+ // No reload after this: a reload gives the page a fresh navigator and silently drops the
4681
+ // override โ€” which is what the old reload-to-apply step did to every emulation.
4682
+ const overrideJS = buildNavigatorOverrideJS(target);
4683
+ if (overrideJS) await runJS(overrideJS);
4684
+
4685
+ const actual = await _relayoutAndMeasure();
4686
+ const notes = [];
4687
+ if (overrideJS) notes.push("navigator.userAgent/platform/maxTouchPoints are overridden for this page's JavaScript only: the HTTP User-Agent header is unchanged, and a reload or navigation clears the override โ€” call safari_emulate again after navigating.");
4688
+ if (actual && actual[0] > target.width) notes.push(`Safari kept the window wider than ${target.width}px (page viewport ${actual[0]}px).`);
4689
+ if (actual && actual[1] < target.height) notes.push(`The screen is too short for a ${target.height}px viewport โ€” the page gets ${actual[1]}px.`);
4690
+ return JSON.stringify({ device: target.name, viewport: { requested: [target.width, target.height], actual }, userAgent: target.ua || "(unchanged)", notes });
4691
+ }
4692
+
4693
+ // A background tab keeps its old layout โ€” innerWidth, media queries, even outerWidth โ€” until
4694
+ // it is shown (seen on Safari 27: 1512px after a resize to 402 until the tab was fronted). Show
4695
+ // ours for a moment (selection restored, Safari never activated) so the new size sticks.
4696
+ async function _relayoutAndMeasure() {
4697
+ let size = null;
4698
+ await _withTargetTabFronted(async () => {
4699
+ await new Promise((r) => setTimeout(r, 250));
4700
+ try { size = JSON.parse(await runJS("JSON.stringify([window.innerWidth, window.innerHeight])")); } catch (_e) { /* reported as null */ }
4648
4701
  });
4702
+ return size;
4649
4703
  }
4650
4704
 
4651
4705
  export async function resetEmulation() {
4652
4706
  await refreshTargetWindow();
4653
- // Reset user agent โ€” remove the defineProperty override set by emulate()
4654
- await runJS(
4655
- "try{var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent');if(d){Object.defineProperty(navigator,'userAgent',d);}else{delete navigator.userAgent;}}catch(_){}"
4656
- );
4657
- // Maximize window
4658
- await osascript(
4659
- `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, 1440, 900}`
4660
- );
4661
- // Reload + wait for load โ€” polled from Node (`do JavaScript` can't await an in-page loop).
4662
- const navIndex = _st().activeTabIndex;
4663
- await runJS("location.reload()", { tabIndex: navIndex });
4664
- await new Promise(r => setTimeout(r, 200));
4665
- await _pollReadyAndRead(navIndex);
4666
- return "Emulation reset to desktop";
4707
+ const st = _st();
4708
+ const bounds = st.preEmulationBounds || "0,0,1440,900";
4709
+ st.preEmulationBounds = null;
4710
+ await osascriptFast(`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`);
4711
+ // Deleting the marked getters restores Navigator.prototype's โ€” no reload, so page state survives.
4712
+ await runJS(RESET_NAVIGATOR_JS);
4713
+ const viewport = await _relayoutAndMeasure();
4714
+ return `Emulation reset: window bounds {${bounds}}, viewport ${viewport ? viewport.join("ร—") : "unknown"}, navigator overrides removed`;
4667
4715
  }
4668
4716
 
4669
4717
  // ========== CONSOLE CAPTURE ==========
@@ -5619,10 +5667,12 @@ export async function listIndexedDBs() {
5619
5667
  export async function getCSSCoverage() {
5620
5668
  return evalReturningJSON(
5621
5669
  `
5670
+ ${ALL_SHEETS_FN}
5622
5671
  var results = [];
5623
- for (var i = 0; i < document.styleSheets.length; i++) {
5672
+ var sheets = allSheets();
5673
+ for (var i = 0; i < sheets.length; i++) {
5674
+ var sheet = sheets[i];
5624
5675
  try {
5625
- var sheet = document.styleSheets[i];
5626
5676
  var rules = sheet.cssRules || sheet.rules;
5627
5677
  var total = rules.length;
5628
5678
  var used = 0;
@@ -5637,7 +5687,7 @@ export async function getCSSCoverage() {
5637
5687
  } else { used++; }
5638
5688
  }
5639
5689
  results.push({
5640
- href: sheet.href || '(inline)',
5690
+ href: sheet.href || (sheet.ownerNode ? '(inline)' : '(adopted)'),
5641
5691
  totalRules: total,
5642
5692
  usedRules: used,
5643
5693
  unusedRules: total - used,
@@ -5645,7 +5695,7 @@ export async function getCSSCoverage() {
5645
5695
  unusedSelectors: unused.slice(0, 20),
5646
5696
  });
5647
5697
  } catch(e) {
5648
- results.push({ href: sheet.href || '(inline)', error: 'CORS blocked' });
5698
+ results.push({ href: sheet.href || (sheet.ownerNode ? '(inline)' : '(adopted)'), error: 'CORS blocked' });
5649
5699
  }
5650
5700
  }
5651
5701
  return JSON.stringify(results);
@@ -5735,7 +5785,7 @@ export async function doctor() {
5735
5785
  else aeDetail = m.slice(0, 80);
5736
5786
  }
5737
5787
  add(aeOk, "Apple Events / Automation", aeDetail,
5738
- "System Settings > Privacy & Security > Automation โ†’ enable Safari for your terminal/host app; and Safari > Develop > Allow JavaScript from Apple Events.");
5788
+ "System Settings > Privacy & Security > Automation โ†’ enable Safari for your terminal/host app; and Safari > Settings > Developer > Allow JavaScript from Apple Events.");
5739
5789
 
5740
5790
  // 3-5. Native helper daemon + Accessibility + Screen Recording (one preflight round-trip)
5741
5791
  let pf = null, pfErr = "";
@@ -5751,10 +5801,13 @@ export async function doctor() {
5751
5801
  "It auto-restarts; if this persists, reinstall safari-mcp.");
5752
5802
  add(!!pf && pf.accessibility === true, "Accessibility (native clicks)",
5753
5803
  pf ? (pf.accessibility ? "CGEvent posting permitted" : "NOT permitted โ€” native clicks silently no-op (the #29 root cause)") : "unknown (helper not responding)",
5754
- "System Settings > Privacy & Security > Accessibility โ†’ enable safari-helper, then retry.");
5804
+ "System Settings > Privacy & Security > Accessibility (named \"Device Control and Data Access\" on macOS 27) โ†’ enable safari-helper, then retry.");
5805
+ // macOS attributes the capture to whoever launched this process: the terminal/IDE for a
5806
+ // stdio server, but node itself under launchd (a LaunchAgent daemon has ppid 1) โ€” and a
5807
+ // Homebrew node upgrade changes that path, so the grant has to follow the new binary.
5755
5808
  add(!!pf && pf.screenRecording === true, "Screen Recording (screenshots)",
5756
- pf ? (pf.screenRecording ? "permitted" : "NOT permitted โ€” screenshots will be blank/blocked") : "unknown (helper not responding)",
5757
- "System Settings > Privacy & Security > Screen Recording โ†’ enable your terminal/host app.");
5809
+ pf ? (pf.screenRecording ? "permitted" : "NOT permitted โ€” screenshots taken through AppleScript and safari_save_pdf fail (extension screenshots don't need it)") : "unknown (helper not responding)",
5810
+ `System Settings > Privacy & Security > Screen & System Audio Recording โ†’ enable ${process.ppid === 1 ? `node at ${process.execPath} (this server runs under launchd)` : "the terminal/IDE that launched this server"}.`);
5758
5811
 
5759
5812
  // 6. Helper codesign identity โ€” a stale/ad-hoc id breaks the Accessibility grant on reinstall
5760
5813
  let idOk = false, idDetail = "";
@@ -77,14 +77,14 @@ ensureCodesign();
77
77
  if (process.env.CI || process.env.SAFARI_MCP_SILENT_INSTALL === "1") process.exit(0);
78
78
 
79
79
  const msg = `
80
- ${c.bold}${c.cyan}๐Ÿฆ Safari MCP installed${c.reset} ${c.dim}โ€” 96 native browser tools for AI agents${c.reset}
80
+ ${c.bold}${c.cyan}๐Ÿฆ Safari MCP installed${c.reset} ${c.dim}โ€” native browser tools for AI agents${c.reset}
81
81
 
82
82
  ${c.bold}Next steps:${c.reset}
83
- 1. Enable Safari โ†’ Develop โ†’ ${c.yellow}Allow JavaScript from Apple Events${c.reset}
83
+ 1. Enable Safari โ†’ Settings โ†’ Developer โ†’ ${c.yellow}Allow JavaScript from Apple Events${c.reset}
84
84
  2. Add to your MCP client config:
85
85
  ${c.dim}{ "mcpServers": { "safari": { "command": "npx", "args": ["safari-mcp"] } } }${c.reset}
86
86
  3. ${c.bold}For native_click / native_keyboard${c.reset} (no focus stealing):
87
- System Settings โ†’ Privacy & Security โ†’ ${c.yellow}Accessibility${c.reset} โ†’ add
87
+ System Settings โ†’ Privacy & Security โ†’ ${c.yellow}Accessibility${c.reset} ${c.dim}(macOS 27: Device Control and Data Access)${c.reset} โ†’ add
88
88
  ${c.dim}node_modules/safari-mcp/safari-helper${c.reset} ${c.dim}(or the global install path)${c.reset}
89
89
 
90
90
  ${c.bold}${c.magenta}โญ Found this useful?${c.reset} A star helps others discover it: