safari-mcp 2.15.4 โ 2.15.6
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 +7 -5
- package/package.json +1 -1
- package/safari.js +57 -5
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Native WebKit. ~60% less CPU. Background operation. 97 tools. One `npx` command.
|
|
|
48
48
|
|
|
49
49
|
> ๐ฐ **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)
|
|
50
50
|
|
|
51
|
-
> ๐ **Apple shipped an official Safari MCP** (Safari Technology Preview 247
|
|
51
|
+
> ๐ **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 97 tools. See the full comparison below.
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
@@ -544,21 +544,23 @@ Safari MCP runs locally on your Mac with minimal attack surface:
|
|
|
544
544
|
|
|
545
545
|
### vs Apple's Official Safari MCP (safaridriver)
|
|
546
546
|
|
|
547
|
-
In
|
|
547
|
+
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**.
|
|
548
|
+
|
|
549
|
+
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.
|
|
548
550
|
|
|
549
551
|
| | ๐ฆ safari-mcp *(this repo)* | Apple `safaridriver --mcp` |
|
|
550
552
|
|---|:---:|:---:|
|
|
551
553
|
| **Your real logins / cookies** | โ
Your actual Safari | โ ๏ธ Isolated automation session โ no access to AutoFill or browsing activity |
|
|
552
|
-
| **Runs on** | โ
Stable Safari, every Mac |
|
|
554
|
+
| **Runs on** | โ
Stable Safari, every Mac | โ ๏ธ Safari Technology Preview 247+ or the Safari 27 beta โ not in stable Safari 26.5 |
|
|
553
555
|
| **Background (no focus steal)** | โ
Yes | โ Dedicated window with a "controlled by automation" banner |
|
|
554
556
|
| **Tools** | **97** | ~17 |
|
|
555
557
|
| **Storage** (cookies, localStorage, IndexedDB) | โ
10 tools | โ |
|
|
556
558
|
| **Network mocking + throttling** | โ
Yes | โ Read-only network inspection |
|
|
557
559
|
| **Device emulation** (iPhone, iPad) | โ
Yes | โ ๏ธ Viewport + media type only |
|
|
558
|
-
| **Setup** | `npx safari-mcp` | Enable "remote automation and external agents"
|
|
560
|
+
| **Setup** | `npx safari-mcp` | Enable "remote automation and external agents", then point your client at that build's `safaridriver --mcp` |
|
|
559
561
|
| **Official Apple support** | โ Community (MIT) | โ
Apple, WebDriver-standard |
|
|
560
562
|
|
|
561
|
-
> **When Apple's server is the right pick:** you specifically want a clean-room, WebDriver-standard session for compatibility debugging and you already run
|
|
563
|
+
> **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.**
|
|
562
564
|
|
|
563
565
|
### Why Safari MCP and Not the Other Safari MCP Projects?
|
|
564
566
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.6",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents โ native macOS, zero Chrome overhead. 96 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -1177,6 +1177,45 @@ async function _withTargetTabFronted(fn) {
|
|
|
1177
1177
|
}
|
|
1178
1178
|
}
|
|
1179
1179
|
|
|
1180
|
+
// Atomic tab-identity guard, as a JS prefix. Belongs here and not at a call site: it is the
|
|
1181
|
+
// only check that is independent of how `idx` was resolved, so every path that builds a
|
|
1182
|
+
// targeted `do JavaScript` needs it โ most of all the retry paths, which run precisely when
|
|
1183
|
+
// the index has already proven untrustworthy. Empty only when the caller named a tab
|
|
1184
|
+
// explicitly. See #64.
|
|
1185
|
+
function _tabIdentityGuard(explicitTabIndex) {
|
|
1186
|
+
if (explicitTabIndex) return '';
|
|
1187
|
+
const marker = _st().activeTabMarker;
|
|
1188
|
+
// No marker โ this session owns no tab, so the target is the front document: the tab the
|
|
1189
|
+
// user is looking at. That fallback is intentional for a session that genuinely never
|
|
1190
|
+
// opened one โ but "owns no tab" is also what a *re-initialised* session reports. In
|
|
1191
|
+
// HTTP-daemon mode a dropped transport makes the client re-init, which mints a new
|
|
1192
|
+
// MCP session id, and `_st()` hands it a fresh empty state: `hasOwnedTab` false,
|
|
1193
|
+
// `activeTabMarker` null. Every fail-closed branch keys on `hasOwnedTab`, so they all read
|
|
1194
|
+
// false at exactly the moment an agent is mid-task and already owns a tab โ and the next
|
|
1195
|
+
// op runs, unguarded, on the user's current page.
|
|
1196
|
+
//
|
|
1197
|
+
// The session state died; the marker on the tab did not. A front document carrying any
|
|
1198
|
+
// `MCP_` marker is a tab some MCP session opened and this one does not own, so it is
|
|
1199
|
+
// never a legitimate implicit target โ refuse it. An unmarked front document stays
|
|
1200
|
+
// reachable, which keeps "read the page I'm looking at" working for real fresh sessions.
|
|
1201
|
+
if (!marker) {
|
|
1202
|
+
return `if(typeof window.name==='string'&&window.name.indexOf('MCP_')===0){throw new Error('MCP_FOREIGN_TAB')};`;
|
|
1203
|
+
}
|
|
1204
|
+
return `if(window.name!=='${marker}'&&window.__mcpTabMarker!=='${marker}'){throw new Error('MCP_WRONG_TAB')};`;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// A tripped foreign-tab guard is terminal: with no marker of our own there is nothing to
|
|
1208
|
+
// re-resolve to, and retrying is the guess the guard just refused.
|
|
1209
|
+
function _foreignTabError(where, cause) {
|
|
1210
|
+
return new Error(
|
|
1211
|
+
`Tab tracking lost during ${where} โ the target is a tab opened by another MCP session ` +
|
|
1212
|
+
`that this session does not own (atomic guard fail-closed). This session holds no tab ` +
|
|
1213
|
+
`marker, which is also what a session re-initialised after a transport drop reports. ` +
|
|
1214
|
+
`Call safari_new_tab to open a tab this session owns.`,
|
|
1215
|
+
cause ? { cause } : undefined
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1180
1219
|
// Run JavaScript in Safari โ fastest path, no focus stealing
|
|
1181
1220
|
// Uses osascriptFast (persistent process, ~5ms) for short scripts,
|
|
1182
1221
|
// falls back to osascript (~80ms) for long scripts that exceed stdin limits
|
|
@@ -1223,9 +1262,7 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
1223
1262
|
// does not carry OUR marker. Makes the op fail-closed at EXECUTION time regardless of how
|
|
1224
1263
|
// `idx` resolved โ a drift to the user's tab (shared profile window) throws instead of
|
|
1225
1264
|
// running on their page. Skipped when the caller passed an explicit tabIndex.
|
|
1226
|
-
const _guard = (
|
|
1227
|
-
? `if(window.name!=='${_st().activeTabMarker}'&&window.__mcpTabMarker!=='${_st().activeTabMarker}'){throw new Error('MCP_WRONG_TAB')};`
|
|
1228
|
-
: '';
|
|
1265
|
+
const _guard = _tabIdentityGuard(tabIndex);
|
|
1229
1266
|
const script = `tell application "Safari" to do JavaScript "${_guard}${escaped}" in ${target}`;
|
|
1230
1267
|
try {
|
|
1231
1268
|
if (script.length < 50000) {
|
|
@@ -1237,6 +1274,7 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
1237
1274
|
// the shared profile window). Re-resolve via a full marker scan and retry once on the
|
|
1238
1275
|
// correct tab; if unresolved, fail closed rather than touch the user's tab.
|
|
1239
1276
|
const _gmsg = err.message || '';
|
|
1277
|
+
if (_gmsg.includes('MCP_FOREIGN_TAB')) throw _foreignTabError('runJS', err);
|
|
1240
1278
|
if (_guard && _gmsg.includes('MCP_WRONG_TAB')) {
|
|
1241
1279
|
console.error('[Safari MCP] Atomic guard tripped โ re-resolving marked tab via full scan');
|
|
1242
1280
|
_st().lastResolveTime = 0; _st().activeTabIndex = null;
|
|
@@ -1262,7 +1300,10 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
1262
1300
|
if (newIdx && newIdx !== idx) {
|
|
1263
1301
|
console.error(`[Safari MCP] Tab ghost resolved: ${idx} โ ${newIdx}`);
|
|
1264
1302
|
const newTarget = `tab ${newIdx} of ${getTargetWindowRef()}`;
|
|
1265
|
-
|
|
1303
|
+
// Keep the guard on the retry. `newIdx` came from a re-resolve triggered by the
|
|
1304
|
+
// index being wrong; dropping the check here made the least trustworthy path the
|
|
1305
|
+
// only unchecked one (#64).
|
|
1306
|
+
const retryScript = `tell application "Safari" to do JavaScript "${_guard}${escaped}" in ${newTarget}`;
|
|
1266
1307
|
if (retryScript.length < 50000) return osascriptFast(retryScript, { timeout });
|
|
1267
1308
|
return osascript(retryScript, { timeout });
|
|
1268
1309
|
}
|
|
@@ -1321,7 +1362,10 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
|
|
|
1321
1362
|
.replace(/\n/g, " ")
|
|
1322
1363
|
.replace(/\r/g, "")
|
|
1323
1364
|
.replace(/\t/g, " ");
|
|
1324
|
-
|
|
1365
|
+
// Same execution-time identity check runJS gets. These are the upload / paste-image ops โ
|
|
1366
|
+
// the largest blast radius in the toolset โ and they were the one targeted path with no
|
|
1367
|
+
// guard at all (#64).
|
|
1368
|
+
const appleScript = `tell application "Safari" to do JavaScript "${_tabIdentityGuard(tabIndex)}${escaped}" in ${target}`;
|
|
1325
1369
|
const tmpFile = join(tmpdir(), `safari-mcp-${Date.now()}.scpt`);
|
|
1326
1370
|
await writeFile(tmpFile, appleScript, "utf8");
|
|
1327
1371
|
// Save frontmost app via daemon before subprocess execution
|
|
@@ -1334,6 +1378,14 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
|
|
|
1334
1378
|
maxBuffer: 10 * 1024 * 1024,
|
|
1335
1379
|
});
|
|
1336
1380
|
return stdout.trim();
|
|
1381
|
+
} catch (err) {
|
|
1382
|
+
// No retry here, unlike runJS: these payloads carry file data, and re-running one on a
|
|
1383
|
+
// freshly resolved tab is exactly the guess this guard exists to prevent.
|
|
1384
|
+
if ((err.message || '').includes('MCP_FOREIGN_TAB')) throw _foreignTabError('runJSLarge', err);
|
|
1385
|
+
if ((err.message || '').includes('MCP_WRONG_TAB')) {
|
|
1386
|
+
throw new Error('Tab tracking lost during runJSLarge โ target tab does not carry this session\'s marker (atomic guard fail-closed). Call safari_new_tab to reopen.', { cause: err });
|
|
1387
|
+
}
|
|
1388
|
+
throw err;
|
|
1337
1389
|
} finally {
|
|
1338
1390
|
unlink(tmpFile).catch(() => {});
|
|
1339
1391
|
// Awaited restore โ caller must not return to user-space while Safari is still frontmost.
|