safari-mcp 2.10.10 → 2.11.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/safari.js +386 -146
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.10.10",
3
+ "version": "2.11.0",
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
@@ -25,6 +25,21 @@ let _helperProc = null;
25
25
  const _helperQueue = []; // callbacks waiting for responses
26
26
  let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — only kill after 3
27
27
 
28
+ // ── Helper request serialization ──────────────────────────────────────────
29
+ // The Swift helper runs each request on its own background thread, so it can
30
+ // finish requests out of order. The Node side matches responses to callbacks by
31
+ // FIFO queue position — correct ONLY if at most one request is in flight at a
32
+ // time. This mutex enforces exactly that: every helper round-trip runs strictly
33
+ // one at a time. Helper calls are ~5ms so serializing costs nothing measurable,
34
+ // but it eliminates the response/callback desync that made tab resolution
35
+ // silently return the wrong tab when the user was browsing concurrently.
36
+ let _helperLock = Promise.resolve();
37
+ function _withHelperLock(makePromise) {
38
+ const result = _helperLock.then(makePromise, makePromise);
39
+ _helperLock = result.then(() => {}, () => {});
40
+ return result;
41
+ }
42
+
28
43
  // Reject all pending callbacks when helper crashes
29
44
  function _drainHelperQueue(reason) {
30
45
  while (_helperQueue.length > 0) {
@@ -320,6 +335,52 @@ function getFallbackTarget() {
320
335
  return SAFARI_PROFILE ? `current tab of ${getTargetWindowRef()}` : "front document";
321
336
  }
322
337
 
338
+ // ========== TAB IDENTITY MARKER + VISIBILITY SPOOF ==========
339
+ // Build the JS that stamps our identity onto a tab and keeps it rendering:
340
+ // - window.name : survives EVERY navigation (full loads, redirects,
341
+ // cross-origin). The browser preserves window.name by
342
+ // design — the bulletproof identity that index/URL lack.
343
+ // - window.__mcpTabMarker : survives SPA / same-document routing (secondary marker).
344
+ // - visibility spoof : forces document.visibilityState='visible' so a
345
+ // backgrounded tab keeps rendering. SPAs (e.g. the Meta
346
+ // developer console) blank their main content when hidden;
347
+ // with the user actively switching tabs our automation tab
348
+ // is constantly backgrounded, so without this its content
349
+ // never paints.
350
+ function _buildStampJS(marker) {
351
+ const m = String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
352
+ return "(function(){"
353
+ + "try{window.name='" + m + "';}catch(e){}"
354
+ + "try{window.__mcpTabMarker='" + m + "';}catch(e){}"
355
+ + "try{if(!window.__mcpVisSpoof){window.__mcpVisSpoof=1;"
356
+ + "Object.defineProperty(document,'visibilityState',{configurable:true,get:function(){return 'visible';}});"
357
+ + "Object.defineProperty(document,'hidden',{configurable:true,get:function(){return false;}});"
358
+ + "Object.defineProperty(document,'webkitVisibilityState',{configurable:true,get:function(){return 'visible';}});"
359
+ + "Object.defineProperty(document,'webkitHidden',{configurable:true,get:function(){return false;}});"
360
+ + "var s=function(e){e.stopImmediatePropagation();};"
361
+ + "document.addEventListener('visibilitychange',s,true);"
362
+ + "document.addEventListener('webkitvisibilitychange',s,true);"
363
+ + "try{document.hasFocus=function(){return true;};}catch(e){}"
364
+ + "}}catch(e){}"
365
+ + "return '1';})()";
366
+ }
367
+
368
+ // Stamp identity marker + visibility spoof onto a specific tab.
369
+ // Identity-critical: a missed stamp loses the tab marker, so this is NOT best-effort —
370
+ // a daemon hiccup falls back to the reliable osascript subprocess.
371
+ async function _stampTab(idx) {
372
+ if (!idx || !_activeTabMarker) return;
373
+ const js = _buildStampJS(_activeTabMarker).replace(/"/g, '\\"');
374
+ const script = `tell application "Safari" to do JavaScript "${js}" in tab ${idx} of ${getTargetWindowRef()}`;
375
+ try {
376
+ await osascriptFast(script, { timeout: 5000 });
377
+ } catch {
378
+ // Daemon hiccup — retry via the reliable subprocess. Stamping is identity-critical
379
+ // (a missed stamp loses the tab marker), so it must not be silently best-effort.
380
+ await osascript(script, { timeout: 8000 }).catch(() => {});
381
+ }
382
+ }
383
+
323
384
  // Quick JS execution — exposed for smart-wait checks in index.js
324
385
  export async function runJSQuick(js) { return runJS(js); }
325
386
 
@@ -339,7 +400,7 @@ export async function restoreFocusIfStolen(savedBundleId) {
339
400
  }
340
401
 
341
402
  function _helperHideSafari(timeout = 2000) {
342
- return new Promise((resolve) => {
403
+ return _withHelperLock(() => new Promise((resolve) => {
343
404
  if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
344
405
  let resolved = false;
345
406
  const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
@@ -352,11 +413,11 @@ function _helperHideSafari(timeout = 2000) {
352
413
  _helperQueue.push(cb);
353
414
  try { _helperProc.stdin.write('{"hideSafari":true}\n'); }
354
415
  catch { clearTimeout(timer); resolve(); }
355
- });
416
+ }));
356
417
  }
357
418
 
358
419
  function _helperActivateApp(bundleId, timeout = 2000) {
359
- return new Promise((resolve) => {
420
+ return _withHelperLock(() => new Promise((resolve) => {
360
421
  if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
361
422
  let resolved = false;
362
423
  const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
@@ -369,7 +430,7 @@ function _helperActivateApp(bundleId, timeout = 2000) {
369
430
  _helperQueue.push(cb);
370
431
  try { _helperProc.stdin.write(JSON.stringify({ activateApp: bundleId }) + '\n'); }
371
432
  catch { clearTimeout(timer); resolve(); }
372
- });
433
+ }));
373
434
  }
374
435
 
375
436
  export function setFocusGuard(active) { _focusGuardActive = active; }
@@ -382,40 +443,52 @@ export function setActiveTabURL(url) { _activeTabURL = url; _lastResolveTime = D
382
443
  async function resolveActiveTab() {
383
444
  if (!_activeTabURL && !_activeTabMarker) return _activeTabIndex;
384
445
 
385
- // Strategy 1: window.__mcpTabMarker (bulletproof — survives navigation, redirects, query changes)
386
- // Only attempted if we have a marker AND a cached index hint
446
+ // Strategy 1: identity marker — window.name (survives ALL navigation: full loads,
447
+ // redirects, cross-origin) or window.__mcpTabMarker (survives SPA routing).
448
+ // One AppleScript call loops every tab internally: faster and far more reliable
449
+ // than N separate daemon round-trips (a daemon hiccup mid-scan used to silently
450
+ // mis-resolve to the user's tab).
387
451
  if (_activeTabMarker) {
388
452
  try {
389
453
  const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
390
- // Single JS call: check cached index first, then walk all tabs to find marker
391
- const checkScript = `(function(){return window.__mcpTabMarker==='${safeMarker}'?'1':'0'})()`;
392
- if (_activeTabIndex) {
393
- const matchAtCached = await osascriptFast(
394
- `tell application "Safari" to do JavaScript "${checkScript}" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
395
- ).catch(() => '0');
396
- if (String(matchAtCached).trim() === '1') return _activeTabIndex;
397
- }
398
- // Cached index doesn't match — scan all tabs for marker
399
- const tabCountStr = await osascriptFast(
400
- `tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`
401
- ).catch(() => '0');
402
- const tabCount = Number(tabCountStr) || 0;
403
- for (let i = tabCount; i >= 1; i--) {
404
- const m = await osascriptFast(
405
- `tell application "Safari" to do JavaScript "${checkScript}" in tab ${i} of ${getTargetWindowRef()}`
406
- ).catch(() => '0');
407
- if (String(m).trim() === '1') {
408
- _activeTabIndex = i;
409
- _lastTabCount = tabCount;
410
- return i;
411
- }
454
+ const check = `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
455
+ const scanScript = `tell application "Safari"
456
+ set w to ${getTargetWindowRef()}
457
+ set n to count of tabs of w
458
+ ${_activeTabIndex ? `try
459
+ if n is greater than or equal to ${_activeTabIndex} then
460
+ if (do JavaScript "${check}" in tab ${_activeTabIndex} of w) is "1" then return ${_activeTabIndex}
461
+ end if
462
+ end try` : ''}
463
+ repeat with i from n to 1 by -1
464
+ try
465
+ if (do JavaScript "${check}" in tab i of w) is "1" then return i
466
+ end try
467
+ end repeat
468
+ return 0
469
+ end tell`;
470
+ // Fast daemon first; if it hiccups, retry once via reliable subprocess.
471
+ let res = await osascriptFast(scanScript).catch(() => null);
472
+ if (res === null) res = await osascript(scanScript).catch(() => null);
473
+ if (res !== null) {
474
+ const found = Number(String(res).trim());
475
+ if (found > 0) { _activeTabIndex = found; return found; }
476
+ // Reliable scan completed and the marker is on NO tab — it is genuinely
477
+ // gone (tab closed, or a site overwrote window.name). Drop it; the URL
478
+ // strategy below is the last chance before we fail safe.
479
+ _activeTabMarker = null;
412
480
  }
413
- // Marker not found anywhere — page reloaded or tab closed
414
- _activeTabMarker = null;
481
+ // res === null → both attempts errored; can't verify — keep marker, fall through.
415
482
  } catch { /* fall through to URL strategy */ }
416
483
  }
417
484
 
418
- if (!_activeTabURL) return _activeTabIndex;
485
+ if (!_activeTabURL) {
486
+ // No URL to resolve. If this session owns a tab but the marker is gone, we can
487
+ // no longer positively identify our tab — refuse to return a stale index that
488
+ // may now point at the user's tab. runJS will throw a clear re-anchor error.
489
+ if (_hasOwnedTab && !_activeTabMarker) { _activeTabIndex = null; }
490
+ return _activeTabIndex;
491
+ }
419
492
 
420
493
  try {
421
494
  const safeUrl = _activeTabURL.replace(/"/g, '\\"');
@@ -447,6 +520,13 @@ async function resolveActiveTab() {
447
520
  const tabCount = Number(resultStr.split(':')[1]) || 1;
448
521
  _lastTabCount = tabCount;
449
522
  _activeTabURL = null;
523
+ if (_hasOwnedTab && !_activeTabMarker) {
524
+ // Identity fully lost: marker gone AND URL matches no tab. Returning the
525
+ // stale index could silently target the user's tab. Fail safe — drop it.
526
+ console.error('[Safari MCP] Tab identity lost (marker + URL unresolved) — clearing index to avoid targeting the user\'s tab');
527
+ _activeTabIndex = null;
528
+ return null;
529
+ }
450
530
  if (_activeTabIndex && _activeTabIndex > tabCount) {
451
531
  console.error(`[Safari MCP] Tab ghost proactive fix: index ${_activeTabIndex} > tabCount ${tabCount}, clamping to ${tabCount}`);
452
532
  _activeTabIndex = tabCount;
@@ -538,7 +618,7 @@ async function osascriptFast(script, { timeout = 10000 } = {}) {
538
618
  }
539
619
 
540
620
  function _osascriptFastHelper(script, timeout) {
541
- return new Promise((resolve, reject) => {
621
+ return _withHelperLock(() => new Promise((resolve, reject) => {
542
622
  let resolved = false;
543
623
  const timer = setTimeout(() => {
544
624
  if (resolved) return;
@@ -586,7 +666,7 @@ function _osascriptFastHelper(script, timeout) {
586
666
  clearTimeout(timer);
587
667
  reject(new Error("safari-helper write failed: " + writeErr.message));
588
668
  }
589
- });
669
+ }));
590
670
  }
591
671
 
592
672
  // ========== NATIVE CLICK VIA CGEVENT ==========
@@ -594,7 +674,7 @@ function _osascriptFastHelper(script, timeout) {
594
674
  // This produces isTrusted: true events — bypasses WAF protection (G2, etc.)
595
675
 
596
676
  function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
597
- return new Promise((resolve, reject) => {
677
+ return _withHelperLock(() => new Promise((resolve, reject) => {
598
678
  if (!_helperProc) startHelper();
599
679
  if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
600
680
  reject(new Error("safari-helper not available for native click"));
@@ -627,13 +707,13 @@ function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5
627
707
  if (doubleClick) cmd.click.double = true;
628
708
  if (windowId) cmd.click.windowId = windowId;
629
709
  _helperProc.stdin.write(JSON.stringify(cmd) + "\n");
630
- });
710
+ }));
631
711
  }
632
712
 
633
713
  // Sends a CGEvent hover command to the Swift helper daemon.
634
714
  // Moves the cursor to (x, y), dwells to let tooltips render, optionally restores cursor.
635
715
  function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = true, timeout = 10000) {
636
- return new Promise((resolve, reject) => {
716
+ return _withHelperLock(() => new Promise((resolve, reject) => {
637
717
  if (!_helperProc) startHelper();
638
718
  if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
639
719
  reject(new Error("safari-helper not available for native hover"));
@@ -665,13 +745,13 @@ function _helperNativeHover(x, y, windowId = 0, dwellMs = 500, restoreMouse = tr
665
745
  const cmd = { hover: { x, y, dwellMs, restoreMouse } };
666
746
  if (windowId) cmd.hover.windowId = windowId;
667
747
  _helperProc.stdin.write(JSON.stringify(cmd) + "\n");
668
- });
748
+ }));
669
749
  }
670
750
 
671
751
  // Sends a CGEvent keyboard command to the Swift helper daemon.
672
752
  // No focus stealing — sends key events directly to the target window via PID.
673
753
  function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000) {
674
- return new Promise((resolve, reject) => {
754
+ return _withHelperLock(() => new Promise((resolve, reject) => {
675
755
  if (!_helperProc) startHelper();
676
756
  if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
677
757
  reject(new Error("safari-helper not available for native keyboard"));
@@ -703,14 +783,14 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
703
783
  const cmd = { keyboard: { keyCode, flags } };
704
784
  if (windowId) cmd.keyboard.windowId = windowId;
705
785
  _helperProc.stdin.write(JSON.stringify(cmd) + "\n");
706
- });
786
+ }));
707
787
  }
708
788
 
709
789
  // ========== NATIVE FOCUS OPERATIONS VIA DAEMON ==========
710
790
  // Uses NSRunningApplication — ~0.1ms for get, ~1ms for activate (vs ~90ms AppleScript)
711
791
 
712
792
  function _helperGetFrontApp(timeout = 2000) {
713
- return new Promise((resolve) => {
793
+ return _withHelperLock(() => new Promise((resolve) => {
714
794
  if (!_helperProc || !_helperProc.stdin?.writable) { resolve(null); return; }
715
795
  let resolved = false;
716
796
  const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, timeout);
@@ -723,7 +803,7 @@ function _helperGetFrontApp(timeout = 2000) {
723
803
  _helperQueue.push(cb);
724
804
  try { _helperProc.stdin.write('{"getFrontApp":true}\n'); }
725
805
  catch { clearTimeout(timer); resolve(null); }
726
- });
806
+ }));
727
807
  }
728
808
 
729
809
  // Get Safari window bounds, toolbar height, and window ID for coordinate calculation
@@ -798,14 +878,16 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
798
878
  }
799
879
  return await osascript(script, { timeout });
800
880
  } catch (err) {
801
- // Tab ghost recovery: "Can't get tab X" → re-resolve and retry once
881
+ // Tab ghost recovery: "Can't get tab X" → re-resolve and retry once.
882
+ // Match both apostrophes — Safari emits a typographic apostrophe (U+2019),
883
+ // so a plain "Can't" includes() check silently missed every ghost error.
802
884
  const msg = err.message || '';
803
- if (idx && (msg.includes("Can't get tab") || msg.includes("-1728"))) {
885
+ if (idx && (/[Cc]an.t get tab/.test(msg) || msg.includes("-1728"))) {
804
886
  console.error(`[Safari MCP] Tab ghost detected (tab ${idx}), re-resolving...`);
805
887
  _lastResolveTime = 0; // Force re-resolve
806
888
  _lastTabCount = null; // Invalidate tab count cache
807
889
  _activeTabIndex = null;
808
- if (_activeTabURL) {
890
+ if (_activeTabURL || _activeTabMarker) {
809
891
  const newIdx = await resolveActiveTab();
810
892
  if (newIdx && newIdx !== idx) {
811
893
  console.error(`[Safari MCP] Tab ghost resolved: ${idx} → ${newIdx}`);
@@ -904,11 +986,22 @@ export async function navigate(url) {
904
986
  // Resolve tab by URL first (in case indices shifted)
905
987
  if (_activeTabURL) await resolveActiveTab();
906
988
  _assertNotFallingBackToUserTab('navigate');
907
- const navTarget = _activeTabIndex
908
- ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}`
989
+ // Capture our tab index ONCE. Every internal runJS below targets it explicitly:
990
+ // re-resolving mid-navigation is unsafe — a cross-origin load transiently wipes
991
+ // window.name (a browser privacy feature) and the tracked URL is stale until the
992
+ // new page settles, so resolveActiveTab() would conclude "identity lost" and drop
993
+ // the very tab we are navigating.
994
+ const navIndex = _activeTabIndex;
995
+ const navTarget = navIndex
996
+ ? `tab ${navIndex} of ${getTargetWindowRef()}`
909
997
  : getFallbackTarget();
910
998
  // Step 0: Suppress onbeforeunload dialogs (prevents blocking navigation)
911
- await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
999
+ await runJS("window.onbeforeunload=null", { tabIndex: navIndex, timeout: 2000 }).catch(() => {});
1000
+
1001
+ // Pre-navigation URL, captured before Step 1. Lets the post-load check below
1002
+ // detect a `set URL` that silently no-ops (a cold or crashed Swift daemon):
1003
+ // the readyState poll would otherwise just see the OLD page still loaded.
1004
+ const preNavUrl = await runJS('location.href', { tabIndex: navIndex, timeout: 3000 }).catch(() => '');
912
1005
 
913
1006
  // Step 1: Set URL via fast daemon (~5ms) — don't block daemon with polling
914
1007
  await osascriptFast(
@@ -922,11 +1015,11 @@ export async function navigate(url) {
922
1015
  for (let poll = 0; poll < 80; poll++) {
923
1016
  await new Promise(r => setTimeout(r, 200));
924
1017
  try {
925
- const state = await runJS('document.readyState', { timeout: 5000 });
1018
+ const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
926
1019
  if (state === 'complete' || state === 'interactive') {
927
1020
  result = await runJS(
928
1021
  `JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('\u05D0\u05D9\u05DF \u05D0\u05E4\u05E9\u05E8\u05D5\u05EA')})`,
929
- { timeout: 5000 }
1022
+ { tabIndex: navIndex, timeout: 5000 }
930
1023
  );
931
1024
  if (state === 'complete') break;
932
1025
  // interactive = DOM ready but resources still loading — wait a bit more
@@ -935,6 +1028,34 @@ export async function navigate(url) {
935
1028
  } catch { /* page still loading, retry */ }
936
1029
  }
937
1030
 
1031
+ // If the fast `set URL` above silently no-opped (cold/crashed daemon), the poll
1032
+ // just saw the OLD page already loaded. Detect that — the URL never left
1033
+ // preNavUrl — and retry once through the daemon-independent osascript path.
1034
+ let landedUrl = '';
1035
+ try { landedUrl = JSON.parse(result).url || ''; } catch { /* result not JSON */ }
1036
+ if (preNavUrl && preNavUrl !== targetUrl && (!landedUrl || landedUrl === preNavUrl)) {
1037
+ console.error('[Safari MCP] navigate: fast set-URL did not take effect — retrying via osascript subprocess');
1038
+ await osascript(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`, { timeout: 12000 });
1039
+ for (let rpoll = 0; rpoll < 80; rpoll++) {
1040
+ await new Promise(res => setTimeout(res, 200));
1041
+ try {
1042
+ const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
1043
+ if (state === 'complete' || state === 'interactive') {
1044
+ result = await runJS('JSON.stringify({title:document.title,url:location.href})', { tabIndex: navIndex, timeout: 5000 });
1045
+ if (state === 'complete') break;
1046
+ if (rpoll > 10) break;
1047
+ }
1048
+ } catch { /* page still loading, retry */ }
1049
+ }
1050
+ // Still stuck on the pre-navigation URL → the navigation genuinely failed.
1051
+ // Throw rather than returning the stale page as a successful navigation.
1052
+ let retryUrl = '';
1053
+ try { retryUrl = JSON.parse(result).url || ''; } catch { /* result not JSON */ }
1054
+ if (retryUrl && retryUrl === preNavUrl) {
1055
+ throw new Error(`navigate failed: page stayed on ${preNavUrl} — Safari "set URL" to ${targetUrl} had no effect (Safari automation/daemon issue, retry exhausted)`);
1056
+ }
1057
+ }
1058
+
938
1059
  // Inject click helpers in background (non-blocking, for subsequent clicks)
939
1060
  _injectHelpersfast().catch(() => {});
940
1061
 
@@ -943,20 +1064,17 @@ export async function navigate(url) {
943
1064
  const parsed = JSON.parse(result);
944
1065
  if (parsed.blocked && url.startsWith("http://")) {
945
1066
  const httpUrl = url.replace(/"/g, '\\"');
946
- const navTarget = _activeTabIndex
947
- ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}`
948
- : `current tab of ${getTargetWindowRef()}`;
949
1067
  await osascriptFast(
950
1068
  `tell application "Safari" to set URL of ${navTarget} to "${httpUrl}"`
951
1069
  );
952
- // Poll readyState for HTTP retry (same sync approach)
1070
+ // Poll readyState for HTTP retry — target navIndex explicitly (no re-resolve)
953
1071
  let retryResult = '{}';
954
1072
  for (let rp = 0; rp < 40; rp++) {
955
1073
  await new Promise(r => setTimeout(r, 300));
956
1074
  try {
957
- const rs = await runJS('document.readyState', { timeout: 5000 });
1075
+ const rs = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
958
1076
  if (rs === 'complete' || rs === 'interactive') {
959
- retryResult = await runJS('JSON.stringify({title:document.title,url:location.href})', { timeout: 5000 });
1077
+ retryResult = await runJS('JSON.stringify({title:document.title,url:location.href})', { tabIndex: navIndex, timeout: 5000 });
960
1078
  if (rs === 'complete') break;
961
1079
  if (rp > 8) break;
962
1080
  }
@@ -968,7 +1086,9 @@ export async function navigate(url) {
968
1086
  const retryParsed = JSON.parse(retry);
969
1087
  if (retryParsed.url) _activeTabURL = retryParsed.url;
970
1088
  } catch {}
1089
+ _activeTabIndex = navIndex;
971
1090
  _lastResolveTime = Date.now();
1091
+ await _stampTab(navIndex);
972
1092
  return retry;
973
1093
  }
974
1094
  } catch (_) {}
@@ -980,18 +1100,14 @@ export async function navigate(url) {
980
1100
  } catch {
981
1101
  _activeTabURL = targetUrl;
982
1102
  }
1103
+ _activeTabIndex = navIndex;
983
1104
  _lastResolveTime = Date.now();
984
1105
 
985
- // Re-inject the tab marker — navigation creates a new JS context which wipes the
986
- // window.__mcpTabMarker we set in newTab. Without re-injection, resolveActiveTab's
987
- // marker-based search fails on the very next call and falls back to URL search,
988
- // which can mis-target if the tab is still loading or shows an error page.
989
- if (_activeTabIndex && _activeTabMarker) {
990
- const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
991
- await osascriptFast(
992
- `tell application "Safari" to do JavaScript "window.__mcpTabMarker='${safeMarker}'" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
993
- ).catch(() => {});
994
- }
1106
+ // Re-stamp identity marker + visibility spoof onto the settled page. A cross-origin
1107
+ // navigation clears window.name, and any full load wipes __mcpTabMarker and the
1108
+ // visibility spoof — re-stamping keeps resolveActiveTab able to find this tab and
1109
+ // keeps the page rendering even while backgrounded.
1110
+ await _stampTab(navIndex);
995
1111
 
996
1112
  return result;
997
1113
  }
@@ -1025,6 +1141,8 @@ export async function reload(hardReload = false) {
1025
1141
  { timeout: 10000 }
1026
1142
  );
1027
1143
  try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
1144
+ // A reload destroys the JS context — re-stamp marker + visibility spoof.
1145
+ await _stampTab(_activeTabIndex);
1028
1146
  return result;
1029
1147
  }
1030
1148
 
@@ -1042,8 +1160,29 @@ export async function readPage({ selector, maxLength = 50000 } = {}) {
1042
1160
  })()`
1043
1161
  );
1044
1162
  }
1163
+ // innerText needs a built render tree, which Safari may skip for a tab that has
1164
+ // never been foregrounded — it can come back near-empty even though the DOM is
1165
+ // fully present. Detect that and fall back to a layout-independent TreeWalker
1166
+ // text extraction so reads work on a background tab without ever taking focus.
1045
1167
  return runJS(
1046
- `JSON.stringify({title:document.title,url:location.href,text:document.body.innerText.substring(0,${Number(maxLength)})})`
1168
+ `(function(){
1169
+ var max=${Number(maxLength)};
1170
+ var t=document.body.innerText||'';
1171
+ if(t.replace(/\\s/g,'').length<20){
1172
+ var parts=[];
1173
+ var w=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null);
1174
+ var n;
1175
+ while(n=w.nextNode()){
1176
+ var p=n.parentElement;if(!p)continue;
1177
+ var tag=p.tagName;
1178
+ if(tag==='SCRIPT'||tag==='STYLE'||tag==='NOSCRIPT'||tag==='TEMPLATE')continue;
1179
+ var s=(n.textContent||'').replace(/\\s+/g,' ').trim();
1180
+ if(s)parts.push(s);
1181
+ }
1182
+ t=parts.join('\\n');
1183
+ }
1184
+ return JSON.stringify({title:document.title,url:location.href,text:t.substring(0,max)});
1185
+ })()`
1047
1186
  );
1048
1187
  }
1049
1188
 
@@ -1136,25 +1275,33 @@ export async function click({ selector, text, x, y, ref }) {
1136
1275
  // the native popup is dismissed → the JSC eval times out and the tool returns an
1137
1276
  // error after seconds of hang. Detect early and return a clear directive instead.
1138
1277
  const selectGuard = `if(el&&el.tagName==='SELECT'){var opts=[];for(var oi=0;oi<el.options.length&&opts.length<8;oi++){opts.push(el.options[oi].text||el.options[oi].value);}return '__SELECT_GUARD__:'+(el.id||el.name||'select')+': '+opts.join('|');}`;
1278
+ // Page fingerprint, captured synchronously on either side of the click. dispatchEvent
1279
+ // is synchronous, so anything that differs between before/after is a direct effect of
1280
+ // the click's own handlers — there is no window for ambient ad/lazy-load noise. Catches
1281
+ // DOM add/remove, class/attribute/text mutations, navigation, and focus changes.
1282
+ const FP = `(location.href+'|'+document.querySelectorAll('*').length+'|'+document.documentElement.innerHTML.length+'|'+(document.activeElement?(document.activeElement.tagName+'#'+(document.activeElement.id||'')):''))`;
1283
+ // Mirrors mcpClickWithReact (resolve → React-fiber click → synthetic fallback) but also
1284
+ // reports reactFired and whether the page observably changed — so click() can detect a
1285
+ // synthetic click that was silently ignored (isTrusted-gated handlers) and escalate.
1286
+ const coreJS = (finderExpr, notFound) =>
1287
+ `(function(){var el=${finderExpr};if(!el)return JSON.stringify({err:${JSON.stringify(notFound)}});${selectGuard}` +
1288
+ `var target=mcpResolveTarget(el)||el;var before=${FP};` +
1289
+ `var reactFired=false;try{reactFired=mcpReactClick(target);}catch(e){}` +
1290
+ `var anchor=target&&target.closest?target.closest('a[href]'):null;` +
1291
+ `if(!reactFired||anchor)mcpClick(target);` +
1292
+ `return JSON.stringify({tag:target.tagName,text:((target.innerText||target.textContent)||'').trim().substring(0,50),reactFired:!!reactFired,changed:before!==(${FP}),fp:before});})()`;
1293
+
1139
1294
  let result;
1140
1295
  if (ref) {
1141
- result = await clickWithRetry(
1142
- `(function(){var el=mcpFindRef('${ref}');if(!el)return 'Element not found: ref=${ref}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
1143
- );
1296
+ result = await clickWithRetry(coreJS(`mcpFindRef('${ref}')`, `Element not found: ref=${ref}`));
1144
1297
  } else if (selector) {
1145
1298
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1146
- result = await clickWithRetry(
1147
- `(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
1148
- );
1299
+ result = await clickWithRetry(coreJS(`mcpQuerySelectorDeep('${sel}')`, `Element not found: ${selector}`));
1149
1300
  } else if (text) {
1150
1301
  const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1151
- result = await clickWithRetry(
1152
- `(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' "'+((target.innerText||target.textContent)||'').trim().substring(0,50)+'"';})()`
1153
- );
1302
+ result = await clickWithRetry(coreJS(`mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false)`, `Element not found with text: ${text}`));
1154
1303
  } else if (x !== undefined && y !== undefined) {
1155
- result = await clickWithRetry(
1156
- `(function(){var el=mcpElementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${Number(x)},${Number(y)})';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' at (${Number(x)},${Number(y)})';})()`
1157
- );
1304
+ result = await clickWithRetry(coreJS(`mcpElementFromPoint(${Number(x)},${Number(y)})`, `No element at (${Number(x)},${Number(y)})`));
1158
1305
  } else {
1159
1306
  throw new Error("click requires selector, text, or x/y coordinates");
1160
1307
  }
@@ -1163,7 +1310,32 @@ export async function click({ selector, text, x, y, ref }) {
1163
1310
  const detail = result.substring('__SELECT_GUARD__:'.length);
1164
1311
  throw new Error(`Target is a native <select> (${detail}). Use safari_select_option with a value matching one of the options instead — clicking it would open the OS picker and block.`);
1165
1312
  }
1166
- return result;
1313
+
1314
+ // Structured result from coreJS. Fall back to the raw string for forward-compat.
1315
+ let info;
1316
+ try { info = JSON.parse(result); } catch { return result; }
1317
+ if (info.err) return info.err;
1318
+ const label = info.tag + (info.text ? ` "${info.text}"` : '');
1319
+
1320
+ // A React handler fired, or the page observably changed → the click landed.
1321
+ if (info.reactFired || info.changed) return 'Clicked: ' + label;
1322
+
1323
+ // No React handler and no synchronous effect. Give an async handler a brief moment,
1324
+ // then re-check before deciding the click was truly ignored.
1325
+ await new Promise(r => setTimeout(r, 320));
1326
+ const afterFp = await runJS(FP).catch(() => null);
1327
+ if (afterFp != null && afterFp !== info.fp) return 'Clicked: ' + label;
1328
+
1329
+ // Synthetic events were ignored — the handler gates on event.isTrusted (Clutch, G2,
1330
+ // Cloudflare-class sites). Escalate to a real OS-level CGEvent click (isTrusted:true).
1331
+ // If a handler genuinely has no observable effect (pure analytics, slow >320ms async),
1332
+ // this re-fires it once via the native click — an accepted, low-cost trade-off.
1333
+ try {
1334
+ await nativeClick({ selector, text, x, y, ref });
1335
+ return 'Clicked (native fallback — synthetic click had no effect): ' + label;
1336
+ } catch (e) {
1337
+ return 'Clicked: ' + label + ' — ⚠️ synthetic click produced no detectable effect and the native fallback is unavailable (' + ((e && e.message) || e) + '). Retry with safari_native_click.';
1338
+ }
1167
1339
  }
1168
1340
 
1169
1341
  export async function doubleClick({ selector, x, y, ref }) {
@@ -2724,22 +2896,27 @@ export async function newTab(url = "") {
2724
2896
  _lastNewTabAt = Date.now(); // (legacy — grace window superseded by _hasOwnedTab)
2725
2897
  _hasOwnedTab = true; // Permanently true: this session has opened its own tab,
2726
2898
  // so write ops must NEVER fall back to the user's current tab.
2727
- // Set bulletproof tab marker — survives same-tab navigation, redirects, query changes (v2.8.3)
2899
+ // Set bulletproof tab marker — stamped onto the tab by _stampTab() after load.
2900
+ // window.name survives ALL navigation (full loads, redirects, cross-origin);
2901
+ // __mcpTabMarker survives SPA routing.
2728
2902
  _activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
2729
- await osascriptFast(
2730
- `tell application "Safari" to do JavaScript "window.__mcpTabMarker='${_activeTabMarker}'" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
2731
- ).catch(() => {});
2732
- // Wait for page load if URL given
2903
+ // Wait for page load if URL given. Poll readyState from the Node side — Safari's
2904
+ // `do JavaScript` does NOT await async IIFEs, so an in-page wait loop returns
2905
+ // immediately without waiting. Stamping before the page settles loses the marker.
2733
2906
  if (url) {
2734
- try {
2735
- await runJS(
2736
- `(async function(){for(var i=0;i<40;i++){if(location.href!=='about:blank'&&document.readyState==='complete')break;await new Promise(r=>setTimeout(r,250))}return 'ok'})()`,
2737
- { tabIndex: _activeTabIndex, timeout: 12000 }
2738
- );
2739
- } catch {}
2907
+ for (let i = 0; i < 50; i++) {
2908
+ await new Promise(r => setTimeout(r, 200));
2909
+ try {
2910
+ const st = await runJS('document.readyState', { tabIndex: _activeTabIndex, timeout: 5000 });
2911
+ const href = await runJS('location.href', { tabIndex: _activeTabIndex, timeout: 5000 });
2912
+ if ((st === 'complete' || st === 'interactive') && href && href !== 'about:blank') break;
2913
+ } catch { /* tab still loading */ }
2914
+ }
2740
2915
  } else {
2741
2916
  await new Promise(r => setTimeout(r, 200));
2742
2917
  }
2918
+ // Stamp identity marker + visibility spoof onto the loaded document.
2919
+ await _stampTab(_activeTabIndex);
2743
2920
  const info = await runJS(`JSON.stringify({title:document.title,url:location.href,tabIndex:${_activeTabIndex}})`, { tabIndex: _activeTabIndex });
2744
2921
  try {
2745
2922
  const parsed = JSON.parse(info);
@@ -2769,6 +2946,13 @@ export async function closeTab() {
2769
2946
  export async function switchTab(index) {
2770
2947
  const idx = Number(index);
2771
2948
  _activeTabIndex = idx;
2949
+ // Claiming this tab: stamp it with a FRESH identity marker so resolveActiveTab can
2950
+ // re-find it after the user shifts tab indices. A fresh marker (not a reused one)
2951
+ // ensures a previously-claimed tab — which still carries the old marker string —
2952
+ // is never mistaken for this one.
2953
+ _activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
2954
+ _hasOwnedTab = true;
2955
+ await _stampTab(idx);
2772
2956
  // Do NOT visually switch the tab — it brings the Safari window to foreground
2773
2957
  // and interrupts the user. Visual switching only happens in screenshot() when needed.
2774
2958
  // AppleScript `do JavaScript in tab N` works on background tabs without switching.
@@ -2782,89 +2966,145 @@ export async function switchTab(index) {
2782
2966
  const parsed = JSON.parse(result);
2783
2967
  _activeTabURL = parsed.url || null;
2784
2968
  } catch {}
2969
+ _lastResolveTime = Date.now();
2785
2970
  return result;
2786
2971
  }
2787
2972
 
2788
2973
  // ========== WAIT ==========
2789
2974
 
2790
2975
  export async function waitFor({ selector, text, timeout = 10000 }) {
2791
- // Single JS call with internal polling loop — 1 call instead of 20+
2792
- const safeSelector = selector ? selector.replace(/'/g, "\\'") : "";
2793
- const safeText = text ? text.replace(/'/g, "\\'") : "";
2794
- const result = await runJS(
2795
- `(async function(){
2796
- var deadline = Date.now() + ${Number(timeout)};
2797
- while (Date.now() < deadline) {
2798
- ${safeSelector ? `if (document.querySelector('${safeSelector}')) return 'Found: ${safeSelector}';` : ""}
2799
- ${safeText ? `if (document.body && document.body.innerText.includes('${safeText}')) return 'Found text: ${safeText}';` : ""}
2800
- await new Promise(function(r){setTimeout(r, 50)});
2801
- }
2802
- return 'TIMEOUT';
2803
- })()`,
2804
- { timeout: timeout + 2000 }
2805
- );
2806
- if (result === "TIMEOUT") {
2807
- throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
2976
+ // `do JavaScript` can't await, so an in-page async wait loop returns immediately
2977
+ // (handing back an unsettled promise) instead of waiting. The wait loop runs on
2978
+ // the Node side: each tick re-evaluates one SYNCHRONOUS check against the page.
2979
+ const safeSelector = selector ? selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : "";
2980
+ const safeText = text ? text.replace(/\\/g, "\\\\").replace(/'/g, "\\'") : "";
2981
+ if (!safeSelector && !safeText) {
2982
+ throw new Error("waitFor requires selector or text");
2808
2983
  }
2809
- return result;
2984
+ const checkJs = `(function(){` +
2985
+ (safeSelector ? `if(document.querySelector('${safeSelector}'))return 'Found: ${safeSelector}';` : "") +
2986
+ (safeText ? `if(document.body&&document.body.innerText.includes('${safeText}'))return 'Found text: ${safeText}';` : "") +
2987
+ `return '';})()`;
2988
+ const deadline = Date.now() + Number(timeout);
2989
+ while (Date.now() < deadline) {
2990
+ const hit = await runJS(checkJs, { timeout: 5000 }).catch(() => "");
2991
+ if (hit) return hit;
2992
+ await new Promise(r => setTimeout(r, 100));
2993
+ }
2994
+ throw new Error(`Timeout waiting for ${selector || text} (${timeout}ms)`);
2810
2995
  }
2811
2996
 
2812
2997
  // ========== EVALUATE ==========
2813
2998
 
2814
- export async function evaluate({ script }) {
2815
- let js = script.trim();
2816
-
2817
- const isAsync = /\bawait\b/.test(js) || /\.then\s*\(/.test(js) || /^async\b/.test(js) || /\bfetch\s*\(/.test(js);
2818
-
2819
- if (isAsync) {
2820
- // Single combined call: execute async + return result in ONE runJS (no polling!)
2821
- const result = await runJS(
2822
- `(async function(){
2823
- try{
2824
- var v = await(async function(){${js}})();
2825
- return JSON.stringify({v: v !== undefined && v !== null ? (typeof v === 'object' ? JSON.stringify(v) : String(v)) : null});
2826
- }catch(e){
2827
- return JSON.stringify({e: e.message});
2828
- }
2829
- })()`,
2830
- { timeout: 35000 }
2831
- );
2832
- try {
2833
- const parsed = JSON.parse(result);
2834
- if (parsed.e) return `Error: ${parsed.e}`;
2835
- return parsed.v !== undefined && parsed.v !== null ? String(parsed.v) : '(undefined)';
2836
- } catch { return result || '(undefined)'; }
2837
- }
2838
-
2999
+ // Build the expression to evaluate from a user script. Pure (no Safari calls) so
3000
+ // it can be unit-tested directly — see scripts/test-evaluate-wrapping.js.
3001
+ export function _buildEvalExpr(js) {
3002
+ // Async iff the *result* is a promise to wait on. `fetch(` alone is NOT async —
3003
+ // an un-awaited fetch is fire-and-forget; only await / .then() / a leading
3004
+ // `async` make the result thenable.
3005
+ const isAsync = /\bawait\b/.test(js) || /\.then\s*\(/.test(js) || /^async\b/.test(js);
3006
+ // Statement keywords: a script starting with one is never a bare expression,
3007
+ // and `return (<keyword> ...)` would be a syntax error.
3008
+ const NON_EXPR = /^(var|let|const|return|if|for|while|switch|try|do|throw)\b/;
2839
3009
  const isIIFE = /^\((?:async\s+)?function/.test(js) || /^\((?:async\s+)?\(/.test(js);
2840
- const isSimpleExpression = !js.includes(';') && !js.includes('\n') && !js.startsWith('var ') && !js.startsWith('let ') && !js.startsWith('const ');
2841
-
2842
- if (!isIIFE && !isSimpleExpression) {
3010
+ const isSimpleExpression = !js.includes(';') && !js.includes('\n') && !NON_EXPR.test(js);
3011
+
3012
+ let expr;
3013
+ if (isIIFE) {
3014
+ expr = js;
3015
+ } else if (isSimpleExpression) {
3016
+ // A bare expression — usable as-is for sync; async needs an awaiting wrapper.
3017
+ expr = isAsync ? `(async function(){return (${js})})()` : js;
3018
+ } else {
3019
+ // Multi-statement: prepend `return` to the last value-producing line when it
3020
+ // can safely take one; otherwise fall back to indirect-eval completion value.
2843
3021
  const lines = js.split('\n');
2844
3022
  let addedReturn = false;
2845
3023
  for (let i = lines.length - 1; i >= 0; i--) {
2846
3024
  const line = lines[i].trim();
2847
3025
  if (!line || line.startsWith('//')) continue;
2848
- if (line.startsWith('return ') || line.startsWith('return;')) { addedReturn = true; break; }
2849
- if (line.endsWith('}') || line.startsWith('var ') || line.startsWith('let ') || line.startsWith('const ')) break;
3026
+ if (line.startsWith('return ') || line.startsWith('return;') || line === 'return') {
3027
+ addedReturn = true; break;
3028
+ }
3029
+ // A block-closer (`}`, `})`, `})()`), a block body (ends with `}`) or a
3030
+ // statement keyword can't take a prepended `return`.
3031
+ if (line.startsWith('}') || line.endsWith('}') || NON_EXPR.test(line)) break;
2850
3032
  lines[i] = 'return ' + lines[i];
2851
3033
  addedReturn = true;
2852
3034
  break;
2853
3035
  }
2854
3036
  if (addedReturn) {
2855
- js = '(function(){' + lines.join('\n') + '})()';
3037
+ expr = `(${isAsync ? 'async function' : 'function'}(){${lines.join('\n')}})()`;
3038
+ } else if (isAsync) {
3039
+ // No safe return slot — run the body in an async IIFE (value may be undefined).
3040
+ expr = `(async function(){${js}})()`;
2856
3041
  } else {
2857
- // Last line ends with } (if/else/for/while) or starts with var/let/const — can't prepend return.
2858
- // Use indirect eval to capture completion value. Falls back to plain IIFE on CSP-strict pages.
2859
- // eslint-disable-next-line no-eval
3042
+ // Indirect eval yields the completion value of an arbitrary statement list;
3043
+ // the catch re-runs the body plainly when a strict CSP blocks eval.
2860
3044
  const escaped = js.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
2861
- js = "(function(){ try { return (0,eval)('" + escaped + "') } catch(_e) { " + js.replace(/\n/g, ' ') + " } })()";
3045
+ expr = "(function(){ try { return (0,eval)('" + escaped + "') } catch(_e) { " + js.replace(/\n/g, ' ') + " } })()";
2862
3046
  }
2863
3047
  }
3048
+ return { isAsync, expr };
3049
+ }
2864
3050
 
2865
- // Wrap in IIFE with try/catch. Safari's `do JavaScript` only returns values from single expressions —
2866
- // multi-statement scripts (var x; try{} x) return nothing. So the entire wrapper must be one IIFE.
2867
- const wrappedJs = `(function(){ try { return (${js}); } catch(__mcpErr) { return 'Error: ' + __mcpErr.message; } })()`;
3051
+ // Async scripts can't be awaited through AppleScript `do JavaScript` — it returns
3052
+ // the moment the synchronous portion finishes, handing back an unsettled Promise.
3053
+ // So the work is started fire-and-forget into a page global, then that global is
3054
+ // polled synchronously from the Node side (the same pattern navigate() uses).
3055
+ async function _evaluateAsync(expr) {
3056
+ // Token is identifier-safe (base36 → [0-9a-z], `_` prefix) so `window.<token>`
3057
+ // dot access needs no quoting/escaping through the AppleScript bridge.
3058
+ const token = '__mcpEval_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
3059
+ const slot = 'window.' + token;
3060
+ // A SYNC outer function installs the globals, starts the async work (NOT awaited
3061
+ // here — `do JavaScript` would not await it anyway) and returns immediately.
3062
+ const kickoff =
3063
+ `(function(){${slot}={done:false};(async function(){try{` +
3064
+ `var __v=await (${expr});` +
3065
+ `${slot}.val=(__v===undefined||__v===null)?null:(typeof __v==='object'?JSON.stringify(__v):String(__v));` +
3066
+ `}catch(__e){${slot}.err=(__e&&__e.message)||String(__e);}` +
3067
+ `finally{${slot}.done=true;}})();return 'ok';})()`;
3068
+ const started = await runJS(kickoff, { timeout: 10000 });
3069
+ if (started !== 'ok') {
3070
+ return typeof started === 'string' && started ? started : '(no return value)';
3071
+ }
3072
+ // Poll the result global until the async work settles (35s budget).
3073
+ const pollJs =
3074
+ `(function(){var s=${slot};if(!s)return '__MCP_GONE__';` +
3075
+ `if(!s.done)return '';return JSON.stringify({v:s.val,e:s.err});})()`;
3076
+ const deadline = Date.now() + 35000;
3077
+ let raw = '';
3078
+ while (Date.now() < deadline) {
3079
+ await new Promise(r => setTimeout(r, 120));
3080
+ raw = await runJS(pollJs, { timeout: 5000 }).catch(() => '');
3081
+ if (raw === '__MCP_GONE__') {
3082
+ return '(no return value — page navigated away during async evaluation)';
3083
+ }
3084
+ if (raw) break;
3085
+ }
3086
+ // Best-effort cleanup of the page global.
3087
+ runJS(`(function(){try{delete ${slot};}catch(__e){${slot}=undefined;}return '';})()`).catch(() => {});
3088
+ if (!raw) {
3089
+ throw new Error('safari_evaluate: async script did not settle within 35s');
3090
+ }
3091
+ try {
3092
+ const parsed = JSON.parse(raw);
3093
+ if (parsed.e) return 'Error: ' + parsed.e;
3094
+ return parsed.v !== undefined && parsed.v !== null ? String(parsed.v) : '(no return value)';
3095
+ } catch {
3096
+ return raw;
3097
+ }
3098
+ }
3099
+
3100
+ export async function evaluate({ script }) {
3101
+ const js = (script || '').trim();
3102
+ if (!js) return '(no return value)';
3103
+ const { isAsync, expr } = _buildEvalExpr(js);
3104
+ if (isAsync) return _evaluateAsync(expr);
3105
+ // Sync: a single `do JavaScript` over one expression. `do JavaScript` only
3106
+ // returns the value of a single expression, so the whole script is one IIFE.
3107
+ const wrappedJs = `(function(){ try { return (${expr}); } catch(__mcpErr) { return 'Error: ' + __mcpErr.message; } })()`;
2868
3108
  if (process.env.MCP_DEBUG) console.error('[evaluate] wrapped:', wrappedJs.substring(0, 300));
2869
3109
  const result = await runJS(wrappedJs);
2870
3110
  if (result === null || result === undefined || result === '') {