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 +19 -16
- package/extension/background.js +187 -116
- package/extension/manifest.json +1 -1
- package/glama.json +1 -1
- package/index.js +7 -10
- package/injected-validators.js +12 -5
- package/mcp-helpers.js +5 -1
- package/package.json +2 -2
- package/response.js +10 -4
- package/safari.js +118 -65
- package/scripts/postinstall.cjs +3 -3
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
|
|
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 โ
|
|
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
|
|
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
|
-
|
|
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
|
|
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** |
|
|
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
|
|
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
|
|
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 โ
|
|
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
|
|
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 โ
|
|
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 โ
|
|
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
|
|
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 |
|
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
1264
|
-
|
|
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
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
1568
|
-
const iframeResult = await execInFirstMatchingFrameMutating(
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
3953
|
-
//
|
|
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
|
}
|
package/extension/manifest.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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");
|
package/injected-validators.js
CHANGED
|
@@ -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 =
|
|
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' ? '
|
|
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
|
|
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 โ
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
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
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
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
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
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
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
await
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
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
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
);
|
|
4657
|
-
//
|
|
4658
|
-
await
|
|
4659
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
|
5757
|
-
|
|
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 = "";
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -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}โ
|
|
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 โ
|
|
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:
|