safari-mcp 2.12.0 → 2.14.0
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 +8 -0
- package/extension/background.js +88 -7
- package/extension/content.js +12 -2
- package/index.js +409 -158
- package/injected-escape.js +18 -0
- package/injected-validators.js +126 -0
- package/ownership-match.js +68 -0
- package/package.json +22 -6
- package/response.js +30 -0
- package/safari-helper +0 -0
- package/safari-helper.swift +89 -15
- package/safari.js +540 -150
- package/scripts/postinstall.cjs +33 -6
- package/index.d.ts +0 -218
package/README.md
CHANGED
|
@@ -478,6 +478,14 @@ The recommended pattern for AI agents using Safari MCP:
|
|
|
478
478
|
|------|-------------|
|
|
479
479
|
| `safari_run_script` | Run multiple actions in a single call (batch) |
|
|
480
480
|
|
|
481
|
+
### iOS & WebKit Validation (4)
|
|
482
|
+
| Tool | Description |
|
|
483
|
+
|------|-------------|
|
|
484
|
+
| `safari_inspect_viewport` | Validate the `<meta name=viewport>` tag for iOS Safari (device-width, zoom/WCAG, viewport-fit) |
|
|
485
|
+
| `safari_safe_area_insets` | Read live safe-area-inset values + viewport-fit / `env()` usage (notch / Dynamic Island) |
|
|
486
|
+
| `safari_check_pwa` | Audit iOS "Add to Home Screen" / PWA readiness (apple-touch-icon, manifest, theme-color, splash) |
|
|
487
|
+
| `safari_webkit_compat` | Check page CSS against this Safari via `CSS.supports()` — unsupported props, missing `-webkit-` prefixes, known quirks |
|
|
488
|
+
|
|
481
489
|
</details>
|
|
482
490
|
|
|
483
491
|
---
|
package/extension/background.js
CHANGED
|
@@ -67,8 +67,13 @@ function updateBadge(text) {
|
|
|
67
67
|
|
|
68
68
|
// ========== HTTP LONG-POLLING TRANSPORT ==========
|
|
69
69
|
|
|
70
|
+
let _connecting = false; // re-entrancy lock — the startup promise and the alarm can race into connect()
|
|
70
71
|
async function connect() {
|
|
71
72
|
if (!_enabled) return;
|
|
73
|
+
// One connect at a time: two near-simultaneous calls (cold start + alarm wake)
|
|
74
|
+
// could each spawn a poll loop before the other assigned pollAbort.
|
|
75
|
+
if (_connecting) return;
|
|
76
|
+
_connecting = true;
|
|
72
77
|
// Cancel any existing poll
|
|
73
78
|
if (pollAbort) {
|
|
74
79
|
try { pollAbort.abort(); } catch {}
|
|
@@ -91,6 +96,7 @@ async function connect() {
|
|
|
91
96
|
if (!isCorrectProfile) {
|
|
92
97
|
console.log(`Safari MCP: wrong profile — server wants "${data.profile}", disconnecting`);
|
|
93
98
|
updateBadge("OFF");
|
|
99
|
+
_connecting = false;
|
|
94
100
|
return; // Do NOT poll — let the correct profile's extension handle commands
|
|
95
101
|
}
|
|
96
102
|
// Notify server that we passed profile verification
|
|
@@ -104,6 +110,7 @@ async function connect() {
|
|
|
104
110
|
_reconnectDelay = 3000; // Reset backoff on success
|
|
105
111
|
updateBadge("ON");
|
|
106
112
|
_startHeartbeat(); // Keep service worker alive between polls
|
|
113
|
+
_connecting = false;
|
|
107
114
|
pollForCommands();
|
|
108
115
|
return;
|
|
109
116
|
}
|
|
@@ -113,6 +120,7 @@ async function connect() {
|
|
|
113
120
|
isConnected = false;
|
|
114
121
|
updateBadge("");
|
|
115
122
|
scheduleReconnect();
|
|
123
|
+
_connecting = false;
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
function scheduleReconnect() {
|
|
@@ -206,6 +214,10 @@ async function handleCommand(type, payload) {
|
|
|
206
214
|
throw new Error("Tab belongs to a different profile — refusing to operate on personal tabs");
|
|
207
215
|
}
|
|
208
216
|
|
|
217
|
+
// Rehydrate owned-tab state after a possible service-worker restart BEFORE
|
|
218
|
+
// consulting the guard — an empty post-restart Map used to disable it entirely.
|
|
219
|
+
await _hydrateOwnedTabs();
|
|
220
|
+
|
|
209
221
|
// ========== TAB OWNERSHIP GUARD ==========
|
|
210
222
|
// Block write operations on tabs not opened by this session.
|
|
211
223
|
// new_tab is always allowed (it creates owned tabs). Read-only ops are allowed on any tab.
|
|
@@ -347,7 +359,11 @@ async function handleCommand(type, payload) {
|
|
|
347
359
|
// Strategy 2: Script element injection (works when inline scripts are allowed)
|
|
348
360
|
const injectResult = await execInTab(async (script) => {
|
|
349
361
|
return await new Promise((resolve) => {
|
|
350
|
-
|
|
362
|
+
// Unpredictable key — a Date.now()-based name let a hostile page pre-seed
|
|
363
|
+
// window["__mcp_eval_<now>"] with a fabricated {done:true,v:...} result.
|
|
364
|
+
const id = "__mcp_eval_" + (crypto.randomUUID
|
|
365
|
+
? crypto.randomUUID().replace(/-/g, "")
|
|
366
|
+
: Date.now().toString(36) + Math.random().toString(36).slice(2));
|
|
351
367
|
window[id] = { done: false };
|
|
352
368
|
const s = document.createElement("script");
|
|
353
369
|
const code = "try{var __r=(function(){" + script + "})();if(__r&&typeof __r.then==='function'){__r.then(function(v){window['" + id + "']={done:true,v:v};}).catch(function(e){window['" + id + "']={done:true,e:e.message};});}else{window['" + id + "']={done:true,v:__r};}}catch(e){window['" + id + "']={done:true,e:e.message};}";
|
|
@@ -1087,16 +1103,33 @@ async function handleCommand(type, payload) {
|
|
|
1087
1103
|
}
|
|
1088
1104
|
|
|
1089
1105
|
case "close_tab": {
|
|
1106
|
+
// ── Guard: never remove a window's LAST tab — doing so closes the window
|
|
1107
|
+
// (quitting Safari if it's the only one, or making a profile-targeted
|
|
1108
|
+
// window vanish). Per-window, not global, so other-profile windows don't
|
|
1109
|
+
// mask it. If the target window is down to one tab, blank it instead.
|
|
1110
|
+
// Mirrors safari.js closeTab().
|
|
1111
|
+
const _winQuery = _profileWindowId ? { windowId: _profileWindowId } : { currentWindow: true };
|
|
1112
|
+
const _winTabs = await browser.tabs.query(_winQuery);
|
|
1113
|
+
const _isLastTab = _winTabs.length <= 1;
|
|
1090
1114
|
if (payload.index) {
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1115
|
+
// Resolve the target from the SAME query as the last-tab count — a second
|
|
1116
|
+
// query here was a TOCTOU window (a tab opened/closed between the two awaits
|
|
1117
|
+
// could resolve the wrong tab or leave a stale last-tab verdict).
|
|
1118
|
+
const target = _winTabs[payload.index - 1];
|
|
1094
1119
|
if (target) {
|
|
1095
1120
|
_removeOwnedTab(sessionId, target.id);
|
|
1121
|
+
if (_isLastTab) {
|
|
1122
|
+
await browser.tabs.update(target.id, { url: "about:blank" });
|
|
1123
|
+
return "Last remaining tab blanked instead of closed (closing it would quit Safari)";
|
|
1124
|
+
}
|
|
1096
1125
|
await browser.tabs.remove(target.id);
|
|
1097
1126
|
}
|
|
1098
1127
|
} else {
|
|
1099
1128
|
_removeOwnedTab(sessionId, tabId);
|
|
1129
|
+
if (_isLastTab) {
|
|
1130
|
+
await browser.tabs.update(tabId, { url: "about:blank" });
|
|
1131
|
+
return "Last remaining tab blanked instead of closed (closing it would quit Safari)";
|
|
1132
|
+
}
|
|
1100
1133
|
await browser.tabs.remove(tabId);
|
|
1101
1134
|
}
|
|
1102
1135
|
return "Tab closed";
|
|
@@ -1166,6 +1199,13 @@ async function handleCommand(type, payload) {
|
|
|
1166
1199
|
}).catch(() => {});
|
|
1167
1200
|
await browser.tabs.update(tabId, { url: payload.url });
|
|
1168
1201
|
await waitForTabLoad(tabId, payload.timeout || 30000);
|
|
1202
|
+
// Update the session cache like every other navigation command — without this,
|
|
1203
|
+
// the next command within TAB_CACHE_MS resolved against the PRE-navigation URL
|
|
1204
|
+
// and could fall through to the user's active tab.
|
|
1205
|
+
try {
|
|
1206
|
+
const updated = await browser.tabs.get(tabId);
|
|
1207
|
+
_setSessionTab(sessionId, updated.id, updated.url);
|
|
1208
|
+
} catch {}
|
|
1169
1209
|
const maxLen = payload.maxLength || 50000;
|
|
1170
1210
|
const results = await browser.scripting.executeScript({
|
|
1171
1211
|
target: { tabId },
|
|
@@ -1739,16 +1779,47 @@ const MAX_SESSIONS = 50; // Hard cap on session cache size
|
|
|
1739
1779
|
// Prevents operating on user's tabs — only tabs created via new_tab are "owned".
|
|
1740
1780
|
const _sessionOwnedTabs = new Map(); // sessionId → Set<tabId>
|
|
1741
1781
|
|
|
1782
|
+
// Persist owned-tab IDs in storage.session: it survives the frequent MV3
|
|
1783
|
+
// service-worker terminations (but clears when Safari quits, matching tab-ID
|
|
1784
|
+
// lifetime). Without this, every worker restart wiped the Map — and the
|
|
1785
|
+
// "no tabs owned yet" compatibility path then silently allowed write commands
|
|
1786
|
+
// on ANY tab, including the user's.
|
|
1787
|
+
const _OWNED_TABS_KEY = "mcpSessionOwnedTabs";
|
|
1788
|
+
let _ownedTabsHydrated = false;
|
|
1789
|
+
async function _hydrateOwnedTabs() {
|
|
1790
|
+
if (_ownedTabsHydrated) return;
|
|
1791
|
+
_ownedTabsHydrated = true;
|
|
1792
|
+
try {
|
|
1793
|
+
const data = (await browser.storage.session.get(_OWNED_TABS_KEY))?.[_OWNED_TABS_KEY];
|
|
1794
|
+
if (data) {
|
|
1795
|
+
for (const [sid, ids] of Object.entries(data)) {
|
|
1796
|
+
if (!_sessionOwnedTabs.has(sid)) _sessionOwnedTabs.set(sid, new Set());
|
|
1797
|
+
const set = _sessionOwnedTabs.get(sid);
|
|
1798
|
+
for (const id of ids) set.add(id);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
} catch {} // storage.session unavailable → behave as before (in-memory only)
|
|
1802
|
+
}
|
|
1803
|
+
function _persistOwnedTabs() {
|
|
1804
|
+
try {
|
|
1805
|
+
const obj = {};
|
|
1806
|
+
for (const [sid, set] of _sessionOwnedTabs) obj[sid] = [...set];
|
|
1807
|
+
browser.storage.session.set({ [_OWNED_TABS_KEY]: obj }).catch(() => {});
|
|
1808
|
+
} catch {}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1742
1811
|
function _addOwnedTab(sessionId, tabId) {
|
|
1743
1812
|
const sid = sessionId || _DEFAULT_SESSION;
|
|
1744
1813
|
if (!_sessionOwnedTabs.has(sid)) _sessionOwnedTabs.set(sid, new Set());
|
|
1745
1814
|
_sessionOwnedTabs.get(sid).add(tabId);
|
|
1815
|
+
_persistOwnedTabs();
|
|
1746
1816
|
}
|
|
1747
1817
|
|
|
1748
1818
|
function _removeOwnedTab(sessionId, tabId) {
|
|
1749
1819
|
const sid = sessionId || _DEFAULT_SESSION;
|
|
1750
1820
|
const set = _sessionOwnedTabs.get(sid);
|
|
1751
1821
|
if (set) set.delete(tabId);
|
|
1822
|
+
_persistOwnedTabs();
|
|
1752
1823
|
}
|
|
1753
1824
|
|
|
1754
1825
|
function _isTabOwnedBySession(sessionId, tabId) {
|
|
@@ -1960,11 +2031,21 @@ async function getTargetTab(tabUrl, sessionId) {
|
|
|
1960
2031
|
match = all.find(t => t.url && (t.url.startsWith(tabUrl) || tabUrl.startsWith(t.url.split("?")[0])));
|
|
1961
2032
|
}
|
|
1962
2033
|
if (match) {
|
|
2034
|
+
// A URL match in a DIFFERENT window must not silently retarget the profile
|
|
2035
|
+
// window — a URL collision with a tab in the user's personal window would
|
|
2036
|
+
// permanently redirect every subsequent command there. Adopt the match's
|
|
2037
|
+
// window only when the tracked profile window no longer exists.
|
|
2038
|
+
if (_profileWindowId && match.windowId !== _profileWindowId) {
|
|
2039
|
+
let profileWindowGone = false;
|
|
2040
|
+
try { await browser.windows.get(_profileWindowId); }
|
|
2041
|
+
catch { profileWindowGone = true; }
|
|
2042
|
+
if (!profileWindowGone) {
|
|
2043
|
+
throw new Error("Tab not found in the MCP profile window (a same-URL tab exists in another window — refusing to cross windows). Use safari_new_tab.");
|
|
2044
|
+
}
|
|
2045
|
+
console.log("Safari MCP: profile window gone — adopting", match.windowId);
|
|
2046
|
+
}
|
|
1963
2047
|
_setSessionTab(sessionId, match.id, tabUrl);
|
|
1964
2048
|
if (!_profileWindowId || match.windowId !== _profileWindowId) {
|
|
1965
|
-
if (_profileWindowId && match.windowId !== _profileWindowId) {
|
|
1966
|
-
console.log("Safari MCP: profile window changed:", _profileWindowId, "→", match.windowId);
|
|
1967
|
-
}
|
|
1968
2049
|
_profileWindowId = match.windowId;
|
|
1969
2050
|
browser.storage.local.set({ mcpProfileWindowId: _profileWindowId }).catch(() => {});
|
|
1970
2051
|
console.log("Safari MCP: profile windowId =", _profileWindowId);
|
package/extension/content.js
CHANGED
|
@@ -20,10 +20,20 @@ if (!window.__mcpShadowPatched) {
|
|
|
20
20
|
}
|
|
21
21
|
return shadow;
|
|
22
22
|
};
|
|
23
|
-
// Expose getter for MCP tools (snapshot, deepQuery, click, fill)
|
|
24
|
-
|
|
23
|
+
// Expose getter for MCP tools (snapshot, deepQuery, click, fill).
|
|
24
|
+
// Non-enumerable + non-writable: pages that know the name can still call it
|
|
25
|
+
// (inherent to MAIN-world injection), but it doesn't surface in enumeration and —
|
|
26
|
+
// more importantly — page scripts can't REPLACE it to feed MCP fake shadow roots.
|
|
27
|
+
var _getShadowRoot = function(el) {
|
|
25
28
|
return el.shadowRoot || _closedRoots.get(el) || null;
|
|
26
29
|
};
|
|
30
|
+
try {
|
|
31
|
+
Object.defineProperty(window, "__mcpGetShadowRoot", {
|
|
32
|
+
value: _getShadowRoot, writable: false, enumerable: false, configurable: false
|
|
33
|
+
});
|
|
34
|
+
} catch (_e) {
|
|
35
|
+
window.__mcpGetShadowRoot = _getShadowRoot;
|
|
36
|
+
}
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
|