safari-mcp 2.10.2 → 2.10.5
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/package.json +1 -1
- package/safari.js +63 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.5",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -166,6 +166,13 @@ let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
|
|
|
166
166
|
// _activeTabIndex or fail loudly — never silently target the user's tab.
|
|
167
167
|
let _lastNewTabAt = 0;
|
|
168
168
|
const NEW_TAB_GRACE_MS = 30000;
|
|
169
|
+
// Becomes true once safari_new_tab is called for the first time in this session.
|
|
170
|
+
// Once true, write operations (navigate/click/fill) MUST NOT fall back to
|
|
171
|
+
// "current tab of window" — that targets the USER'S active tab. The 30s grace
|
|
172
|
+
// window was insufficient: tab tracking can be lost much later in a session
|
|
173
|
+
// (e.g. tab ghost recovery in runJS), and silently falling back to the user's
|
|
174
|
+
// tab caused incidents where the user's working tab was overwritten.
|
|
175
|
+
let _hasOwnedTab = false;
|
|
169
176
|
let _lastResolveTime = 0; // Cache: skip resolve if verified recently
|
|
170
177
|
let _lastTabCount = null; // Track tab count for smart cache invalidation
|
|
171
178
|
let _activeTabMarker = null; // window.__mcpTabMarker — survives same-tab navigation, bulletproof tab identity
|
|
@@ -285,13 +292,18 @@ function isStaleWindowError(err) {
|
|
|
285
292
|
// target whatever tab the user is looking at when our cached index is lost.
|
|
286
293
|
function _assertNotFallingBackToUserTab(opName) {
|
|
287
294
|
if (_activeTabIndex) return; // we have a tracked index — fine
|
|
288
|
-
|
|
289
|
-
|
|
295
|
+
// If we ever opened our own tab in this session, we MUST NOT fall back to
|
|
296
|
+
// "current tab of window" — that's the USER'S active tab. Always throw,
|
|
297
|
+
// regardless of how long ago the tab was opened. The previous 30-second
|
|
298
|
+
// grace window was insufficient: long-running sessions (Reddit warmup,
|
|
299
|
+
// multi-step workflows) routinely exceed it, and the danger persists.
|
|
300
|
+
if (_hasOwnedTab) {
|
|
290
301
|
throw new Error(
|
|
291
|
-
`Tab tracking lost
|
|
292
|
-
`
|
|
302
|
+
`Tab tracking lost — refusing to ${opName} via fallback to "current tab of window" (would target the user's active tab). ` +
|
|
303
|
+
`This session previously opened its own tab via safari_new_tab; re-run safari_new_tab to recover, or call safari_list_tabs and safari_switch_tab to re-anchor to a known tab.`
|
|
293
304
|
);
|
|
294
305
|
}
|
|
306
|
+
// No tab ever owned by this session — fallback to front document is intentional.
|
|
295
307
|
}
|
|
296
308
|
|
|
297
309
|
function getFallbackTarget() {
|
|
@@ -779,7 +791,18 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
|
|
|
779
791
|
return osascript(retryScript, { timeout });
|
|
780
792
|
}
|
|
781
793
|
}
|
|
782
|
-
// If still can't resolve
|
|
794
|
+
// If still can't resolve and we previously owned a tab — refuse to
|
|
795
|
+
// fall back to "current tab of window" (which is the USER'S active tab).
|
|
796
|
+
// Falling back would silently run our JS (potentially writes:
|
|
797
|
+
// document.title=, location.href=) on the user's working page.
|
|
798
|
+
if (_hasOwnedTab) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`Tab tracking lost during runJS — original tab ${idx} no longer exists, and URL-based resolution failed. ` +
|
|
801
|
+
`Refusing to fall back to "current tab of window" (would target user's active tab). ` +
|
|
802
|
+
`Call safari_new_tab to open a fresh tab and retry.`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
// No owned tab in this session — front-document fallback is intentional.
|
|
783
806
|
const fallbackScript = `tell application "Safari" to do JavaScript "${escaped}" in current tab of ${getTargetWindowRef()}`;
|
|
784
807
|
console.error(`[Safari MCP] Falling back to current tab`);
|
|
785
808
|
if (fallbackScript.length < 50000) return osascriptFast(fallbackScript, { timeout });
|
|
@@ -800,6 +823,14 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
|
|
|
800
823
|
if (resolved) { idx = resolved; _lastResolveTime = Date.now(); }
|
|
801
824
|
}
|
|
802
825
|
if (!idx) idx = _activeTabIndex;
|
|
826
|
+
// If we previously owned a tab but lost tracking, refuse to target current tab
|
|
827
|
+
// (which is the USER'S active tab). Same protection as navigate/runJS fallback.
|
|
828
|
+
if (!idx && _hasOwnedTab) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`Tab tracking lost during runJSLarge — refusing to fall back to "current tab of window" (would target user's active tab). ` +
|
|
831
|
+
`Call safari_new_tab to open a fresh tab and retry.`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
803
834
|
const target = idx
|
|
804
835
|
? `tab ${idx} of ${getTargetWindowRef()}`
|
|
805
836
|
: getFallbackTarget();
|
|
@@ -1415,7 +1446,11 @@ export async function fill({ selector, value, ref }) {
|
|
|
1415
1446
|
`if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
|
|
1416
1447
|
// Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
|
|
1417
1448
|
// that don't respond to execCommand but DO handle paste events
|
|
1418
|
-
|
|
1449
|
+
// Pre-clear: select all + delete BEFORE paste. Some editors (X's tweetTextarea_0
|
|
1450
|
+
// when it has URL-prefilled content from /intent/post?text=, Quill in some configs)
|
|
1451
|
+
// append paste content to selection instead of replacing — explicit delete avoids
|
|
1452
|
+
// the duplication seen when the textarea was pre-populated by URL parameters.
|
|
1453
|
+
`try{el.focus();var sel2=window.getSelection();if(sel2.rangeCount){var rng=document.createRange();rng.selectNodeContents(el);sel2.removeAllRanges();sel2.addRange(rng);document.execCommand('delete',false,null);}var dt=new DataTransfer();dt.setData('text/plain','${val}');var pe=new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:dt});var handled=!el.dispatchEvent(pe);if(handled||el.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (synthetic paste)';}}catch(ep){}` +
|
|
1419
1454
|
// Synthetic paste did not verify — in a dialog, route to native paste (CGEvent Cmd+V)
|
|
1420
1455
|
// to produce real isTrusted:true events. Avoids LinkedIn-style dialog dismissal too.
|
|
1421
1456
|
`if(!!el.closest('[role="dialog"]')){return '__NATIVE_PASTE_DIALOG__';}` +
|
|
@@ -1474,6 +1509,23 @@ export async function fill({ selector, value, ref }) {
|
|
|
1474
1509
|
`el.focus();el.click();` +
|
|
1475
1510
|
`var t=document.activeElement||el;` +
|
|
1476
1511
|
charInserts +
|
|
1512
|
+
// Verification: count actual text length vs expected. If char-by-char dropped
|
|
1513
|
+
// intermediate paragraphs (Hashnode-style Tiptap with markdown-like chars at
|
|
1514
|
+
// line starts: `>`, `**`, `[`), fall back to execCommand('insertHTML') with
|
|
1515
|
+
// paragraph-wrapped HTML.
|
|
1516
|
+
`var actualLen=(el.innerText||'').length;` +
|
|
1517
|
+
`var expectedLen=${value.length};` +
|
|
1518
|
+
`if(actualLen<expectedLen*0.6){` +
|
|
1519
|
+
// Clear and re-fill via insertHTML
|
|
1520
|
+
`var sel=window.getSelection();var r=document.createRange();r.selectNodeContents(el);sel.removeAllRanges();sel.addRange(r);` +
|
|
1521
|
+
`document.execCommand('delete',false,null);` +
|
|
1522
|
+
// Build paragraph HTML from the original value
|
|
1523
|
+
`var paras=${JSON.stringify(value)}.split(/\\n\\n+/).filter(function(p){return p.trim().length>0;});` +
|
|
1524
|
+
`var html=paras.map(function(p){var safe=p.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\\n/g,'<br>');return '<p>'+safe+'</p>';}).join('');` +
|
|
1525
|
+
`el.focus();var sel2=window.getSelection();var r2=document.createRange();r2.selectNodeContents(el);r2.collapse(true);sel2.removeAllRanges();sel2.addRange(r2);` +
|
|
1526
|
+
`document.execCommand('insertHTML',false,html);` +
|
|
1527
|
+
`return 'Filled CE (ProseMirror insertHTML fallback, '+(el.innerText||'').length+'/'+expectedLen+')';` +
|
|
1528
|
+
`}` +
|
|
1477
1529
|
`return 'Filled CE (ProseMirror char-by-char)';` +
|
|
1478
1530
|
`})()`
|
|
1479
1531
|
);
|
|
@@ -2606,7 +2658,9 @@ export async function newTab(url = "") {
|
|
|
2606
2658
|
_activeTabURL = url || null;
|
|
2607
2659
|
_lastResolveTime = Date.now();
|
|
2608
2660
|
_lastTabCount = Number(tabCount); // Update tab count cache
|
|
2609
|
-
_lastNewTabAt = Date.now(); //
|
|
2661
|
+
_lastNewTabAt = Date.now(); // (legacy — grace window superseded by _hasOwnedTab)
|
|
2662
|
+
_hasOwnedTab = true; // Permanently true: this session has opened its own tab,
|
|
2663
|
+
// so write ops must NEVER fall back to the user's current tab.
|
|
2610
2664
|
// Set bulletproof tab marker — survives same-tab navigation, redirects, query changes (v2.8.3)
|
|
2611
2665
|
_activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
2612
2666
|
await osascriptFast(
|
|
@@ -4214,6 +4268,8 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
|
|
|
4214
4268
|
let targetUrl = url;
|
|
4215
4269
|
if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
|
|
4216
4270
|
const safeUrl = targetUrl.replace(/"/g, '\\"');
|
|
4271
|
+
if (_activeTabURL) await resolveActiveTab();
|
|
4272
|
+
_assertNotFallingBackToUserTab('navigateAndRead');
|
|
4217
4273
|
const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
|
|
4218
4274
|
await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
|
|
4219
4275
|
_activeTabURL = targetUrl;
|