safari-mcp 2.22.7 → 2.22.9

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 (4) hide show
  1. package/README.md +0 -12
  2. package/index.js +60 -33
  3. package/package.json +1 -1
  4. package/safari.js +58 -31
package/README.md CHANGED
@@ -993,18 +993,6 @@ Other macOS MCP servers that complement Safari MCP:
993
993
 
994
994
  ---
995
995
 
996
- ## Like it? Give it a ⭐
997
-
998
- If Safari MCP saves you from Chrome overhead, **a star helps others discover it:**
999
-
1000
- [![Star this repo](https://img.shields.io/github/stars/achiya-automation/safari-mcp?style=social)](https://github.com/achiya-automation/safari-mcp)
1001
-
1002
- [Share on Twitter/X](https://twitter.com/intent/tweet?text=Safari%20MCP%20%E2%80%94%20Stop%20running%20Chrome%20just%20so%20your%20AI%20agent%20can%20browse.%2098%20tools%2C%20native%20Safari%2C%2060%25%20less%20CPU.&url=https%3A%2F%2Fgithub.com%2Fachiya-automation%2Fsafari-mcp) · [Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fgithub.com%2Fachiya-automation%2Fsafari-mcp) · [Write about it](https://dev.to/)
1003
-
1004
- [![Star History Chart](https://api.star-history.com/svg?repos=achiya-automation/safari-mcp&type=Date)](https://star-history.com/#achiya-automation/safari-mcp&Date)
1005
-
1006
- ---
1007
-
1008
996
  ## Listed On
1009
997
 
1010
998
  [![Glama](https://glama.ai/mcp/servers/achiya-automation/safari-mcp/badges/score.svg)](https://glama.ai/mcp/servers/achiya-automation/safari-mcp) [![MCP Registry](https://img.shields.io/badge/MCP-Registry-purple)](https://registry.modelcontextprotocol.io/) [![MCP Scoreboard](https://mcpscoreboard.com/badge/05977769-8762-4e89-aff3-a0c5776843bb.svg)](https://mcpscoreboard.com/server/05977769-8762-4e89-aff3-a0c5776843bb/) [![CI](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/achiya-automation/safari-mcp/actions/workflows/ci.yml)
package/index.js CHANGED
@@ -1983,6 +1983,39 @@ function _sanitizeTabResult(value) {
1983
1983
  };
1984
1984
  }
1985
1985
 
1986
+ // A receipt is bound to the origin it was minted on, so a cross-origin navigation the
1987
+ // caller itself asked for used to strand every following call ("not valid for this
1988
+ // origin", ~80×/week). The caller chose the destination, so rotating here is safe —
1989
+ // hand back the new receipt instead of letting the caller discover the trap. Every
1990
+ // navigation a caller asks for goes through here: safari_navigate, safari_navigate_and_read
1991
+ // and run_script's navigate and navigateAndRead steps. `usedReceipt` is the receipt the
1992
+ // navigation carried, read before it waited: a call alongside that names another tab makes
1993
+ // that tab the session's current one, and the rotation has to name the tab that navigated.
1994
+ // navigate_and_read answers with its page as a JSON string; a rotated result is an object.
1995
+ async function _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl) {
1996
+ let page = result;
1997
+ if (typeof page === "string") {
1998
+ try { page = JSON.parse(page); } catch { return result; }
1999
+ }
2000
+ const landed = page && typeof page === "object" ? page.url : "";
2001
+ // The origin of the receipt the navigation carried: extensionOrFallback sends the newest one.
2002
+ if (!landed || !usedReceipt || _originOf(landed) === (_receiptOrigins.get(_receiptToken(usedReceipt)) || _originOf(oldUrl))) {
2003
+ return result;
2004
+ }
2005
+ try {
2006
+ // Named for the alias below, as run_script getReceipt names it (see _receiptAliases).
2007
+ const fresh = _sanitizeTabResult(await extensionOrFallback(
2008
+ "get_tab_receipt", { ..._explicitReceipt({ receipt: usedReceipt }) }, () => null
2009
+ ));
2010
+ if (fresh?.receipt) {
2011
+ _aliasReceipt(_receiptToken(usedReceipt), fresh.receipt); // the receipt extensionOrFallback sent
2012
+ _setActiveReceipt(fresh.receipt);
2013
+ return { ...page, receipt: fresh.receipt };
2014
+ }
2015
+ } catch { /* the navigation itself succeeded — a failed rotation must not fail the tool */ }
2016
+ return result;
2017
+ }
2018
+
1986
2019
  function _isBatchSemanticFailure(result) {
1987
2020
  if (Array.isArray(result)) return result.some(_isBatchSemanticFailure);
1988
2021
  if (result && typeof result === "object") return result.ok === false;
@@ -2137,22 +2170,26 @@ async function _runExtensionBatchAction(action, args = {}) {
2137
2170
  case "navigate": {
2138
2171
  const url = String(args.url || "");
2139
2172
  if (!url) throw new Error("navigate requires url");
2173
+ const oldUrl = safari.getActiveTabURL();
2174
+ const usedReceipt = _getActiveReceipt();
2140
2175
  const raw = await extensionOrFallback(
2141
- "navigate", { url, timeout: args.timeout },
2176
+ "navigate", { url, timeout: args.timeout, ..._explicitReceipt({ receipt: usedReceipt }) },
2142
2177
  () => safari.navigate(url)
2143
2178
  );
2144
- return syncResultUrl(raw, url);
2179
+ return _rotateReceiptAfterNavigate(syncResultUrl(raw, url), usedReceipt, oldUrl);
2145
2180
  }
2146
2181
 
2147
2182
  case "navigateAndRead": {
2148
2183
  const url = String(args.url || "");
2149
2184
  if (!url) throw new Error("navigateAndRead requires url");
2185
+ const oldUrl = safari.getActiveTabURL();
2186
+ const usedReceipt = _getActiveReceipt();
2150
2187
  const raw = await extensionOrFallback(
2151
- "navigate_and_read", { url, maxLength: args.maxLength, timeout: args.timeout },
2188
+ "navigate_and_read", { url, maxLength: args.maxLength, timeout: args.timeout, ..._explicitReceipt({ receipt: usedReceipt }) },
2152
2189
  async () => { await safari.navigate(url); return safari.readPage({ maxLength: args.maxLength }); }
2153
2190
  );
2154
2191
  safari.setActiveTabURL(url);
2155
- return normalize(raw);
2192
+ return _rotateReceiptAfterNavigate(normalize(raw), usedReceipt, oldUrl);
2156
2193
  }
2157
2194
 
2158
2195
  case "readPage":
@@ -2569,32 +2606,13 @@ server.tool(
2569
2606
  // Read once, before anything waits: a call alongside that names another tab makes that tab the
2570
2607
  // session's current one, and the rotation below has to name the tab this call navigated.
2571
2608
  const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
2572
- let result = await extensionOrFallback(
2609
+ const result = await extensionOrFallback(
2573
2610
  "navigate", { url, ..._explicitReceipt({ receipt: usedReceipt }) },
2574
2611
  () => safari.navigate(url)
2575
2612
  );
2576
2613
  // Tab kept its identity, just changed URL — drop the stale old URL from ownership.
2577
2614
  if (oldUrl && oldUrl !== url && oldUrl !== 'about:blank') _removeOwnedURL(oldUrl);
2578
- // A receipt is bound to the origin it was minted on, so a cross-origin navigation the
2579
- // caller itself asked for used to strand every following call ("not valid for this
2580
- // origin", ~80×/week). The caller chose the destination, so rotating here is safe —
2581
- // hand back the new receipt instead of letting the caller discover the trap.
2582
- const landed = result && typeof result === "object" ? result.url : "";
2583
- // The origin of the receipt the navigation carried: extensionOrFallback sends the newest one.
2584
- if (landed && _originOf(landed) !== (_receiptOrigins.get(_receiptToken(usedReceipt)) || _originOf(oldUrl)) && usedReceipt) {
2585
- try {
2586
- // Named for the alias below, as run_script getReceipt names it (see _receiptAliases).
2587
- const fresh = _sanitizeTabResult(await extensionOrFallback(
2588
- "get_tab_receipt", { ..._explicitReceipt({ receipt: usedReceipt }) }, () => null
2589
- ));
2590
- if (fresh?.receipt) {
2591
- _aliasReceipt(_receiptToken(usedReceipt), fresh.receipt); // the receipt extensionOrFallback sent
2592
- _setActiveReceipt(fresh.receipt);
2593
- result = { ...result, receipt: fresh.receipt };
2594
- }
2595
- } catch { /* the navigation itself succeeded — a failed rotation must not fail the tool */ }
2596
- }
2597
- return textResult(result);
2615
+ return textResult(await _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl));
2598
2616
  }
2599
2617
  );
2600
2618
 
@@ -2678,7 +2696,7 @@ server.tool(
2678
2696
 
2679
2697
  server.tool(
2680
2698
  "safari_navigate_and_read",
2681
- "Navigate to a URL and return the page content in one step — saves 1 full round-trip vs navigate+read_page. Use instead of safari_navigate + safari_read_page.",
2699
+ "Navigate to a URL and return the page content in one step — saves 1 full round-trip vs navigate+read_page. Use instead of safari_navigate + safari_read_page. Like safari_navigate, returns a fresh `receipt` when the origin changed; use that one from then on.",
2682
2700
  {
2683
2701
  receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
2684
2702
  url: z.string().describe("URL to navigate to"),
@@ -2687,15 +2705,17 @@ server.tool(
2687
2705
  },
2688
2706
  async ({ url, maxLength, timeout, receipt }) => {
2689
2707
  const oldUrl = safari.getActiveTabURL();
2708
+ // Read before anything waits, as safari_navigate does: the rotation names the tab that navigated.
2709
+ const usedReceipt = _receiptToken(receipt || _getActiveReceipt());
2690
2710
  const result = await extensionOrFallback(
2691
- "navigate_and_read", { url, maxLength, timeout, ..._explicitReceipt({ receipt }) },
2711
+ "navigate_and_read", { url, maxLength, timeout, ..._explicitReceipt({ receipt: usedReceipt }) },
2692
2712
  async () => {
2693
2713
  await safari.navigate(url);
2694
2714
  return safari.readPage({ maxLength });
2695
2715
  }
2696
2716
  );
2697
2717
  if (oldUrl && oldUrl !== url && oldUrl !== 'about:blank') _removeOwnedURL(oldUrl);
2698
- return textResult(result);
2718
+ return textResult(await _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl));
2699
2719
  }
2700
2720
  );
2701
2721
 
@@ -3771,14 +3791,19 @@ server.tool(
3771
3791
  "safari_upload_file",
3772
3792
  "Upload a file to a <input type='file'> element via JavaScript DataTransfer — NO file dialog, NO UI interaction. IMPORTANT: Do NOT click the file input before calling this tool — just provide the selector and file path. If a file dialog is already open, this tool will close it first. NOTE: 'verified 0 files' may appear even on success if the site uses a custom upload handler — check visually with safari_snapshot. For an IMAGE going into a composer/editor that should show a thumbnail, pass verifyPreview:true — some sites (Google Business Profile) accept the file handle and flip their UI to 'attached' while ingesting nothing, and the post then publishes with no image.",
3773
3793
  {
3794
+ receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
3774
3795
  selector: z.string().describe("CSS selector of the file input"),
3775
3796
  filePath: z.string().describe("Absolute path to the file to upload"),
3776
3797
  verifyPreview: z.boolean().optional().describe("Require a visible preview (blob:/data: image) to appear; if none does, the synthetic pickup was a ghost and this escalates to a real OS file dialog. Use for images going into a composer."),
3777
3798
  forceNative: z.boolean().optional().describe("Skip synthetic injection and go straight to the real OS file dialog (isTrusted). Needs an unlocked screen and briefly focuses Safari. Use when the site is known to reject synthetic uploads."),
3778
3799
  },
3779
3800
  async (args) => {
3780
- _assertTabOwnership("upload_file");
3781
- const result = await safari.uploadFile(args);
3801
+ // The receipt first: for a caller with no state of its own it is what names the tab, and the
3802
+ // guard has to see it. The upload bypasses extensionOrFallback, so assert ownership here.
3803
+ _assertTabOwnership("upload_file", _explicitReceipt(args));
3804
+ // AppleScript carries the file; it finds the tab by its marker in the profile's first window,
3805
+ // and the extension can say which of the profile's windows holds it (see _locusWindows).
3806
+ const result = await safari.uploadFile({ ...args, locate: _tabLocusFromExtension });
3782
3807
  return textResult(result);
3783
3808
  }
3784
3809
  );
@@ -3789,11 +3814,13 @@ server.tool(
3789
3814
  "safari_paste_image",
3790
3815
  "Paste an image from a local file into the focused element via JS DataTransfer (no clipboard, no focus steal). Works on Medium, dev.to, HackerNoon, TOI, etc.",
3791
3816
  {
3817
+ receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
3792
3818
  filePath: z.string().describe("Absolute path to the image file (PNG, JPG, WebP)"),
3793
3819
  },
3794
- async ({ filePath }) => {
3795
- _assertTabOwnership("paste_image");
3796
- const result = await safari.pasteImageFromFile({ filePath });
3820
+ async ({ filePath, receipt }) => {
3821
+ // As safari_upload_file: the receipt before the guard, and the extension's locus for the scan.
3822
+ _assertTabOwnership("paste_image", _explicitReceipt({ receipt }));
3823
+ const result = await safari.pasteImageFromFile({ filePath, locate: _tabLocusFromExtension });
3797
3824
  return textResult(result);
3798
3825
  }
3799
3826
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.22.7",
3
+ "version": "2.22.9",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -1002,16 +1002,17 @@ function _markerCheckJS(marker) {
1002
1002
  return `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
1003
1003
  }
1004
1004
 
1005
- // Scan the target window for the tab carrying `marker`, trying tab `hint` first. Returns
1006
- // { idx, win }: that tab's index, 0 when the scan completed and no tab carries the marker, and
1007
- // the window scanned (see _windowById); or null when the scan could not be completed.
1008
- async function _scanForMarker(marker, hint) {
1005
+ // Scan the target window (or `inWin`, a `window id N`) for the tab carrying `marker`, trying tab
1006
+ // `hint` first. Returns { idx, win }: that tab's index, 0 when the scan completed and no tab
1007
+ // carries the marker, and the window scanned (see _windowById); or null when the scan could not
1008
+ // be completed.
1009
+ async function _scanForMarker(marker, hint, inWin = null) {
1009
1010
  try {
1010
1011
  const check = _markerCheckJS(marker);
1011
1012
  // One AppleScript call loops every tab internally: faster and far more reliable than N
1012
1013
  // separate daemon round-trips (a daemon hiccup mid-scan used to mis-resolve to the user's tab).
1013
1014
  const scanScript = `tell application "Safari"
1014
- set w to ${getTargetWindowRef()}
1015
+ set w to ${inWin || getTargetWindowRef()}
1015
1016
  set wid to (id of w) as text
1016
1017
  set n to count of tabs of w
1017
1018
  ${hint ? `try
@@ -1129,7 +1130,11 @@ async function resolveActiveTab() {
1129
1130
  //
1130
1131
  // A session that never owned a tab keeps what it tracks, which in the default mode is nothing:
1131
1132
  // null means the front document, the page the user is looking at.
1132
- async function _resolveSessionTab() {
1133
+ // `elsewhere`, when given, answers the other windows (`window id N`) the tab may be in, asked
1134
+ // only once the target window has no tab carrying the marker. A named profile can hold several
1135
+ // windows and the target window is only the first of them, while the extension opens or picks
1136
+ // the session's tab in any of them. The marker proves the tab in whichever window it is found.
1137
+ async function _resolveSessionTab({ elsewhere = null } = {}) {
1133
1138
  const s = _st();
1134
1139
  if (s.hasOwnedTab) {
1135
1140
  let marked = false;
@@ -1154,7 +1159,13 @@ async function _resolveSessionTab() {
1154
1159
  // The marker this scan looks for proves the tab, whatever a parallel call of the session
1155
1160
  // makes of s.activeTabMarker while the scan runs.
1156
1161
  const marker = s.activeTabMarker;
1157
- const found = await _scanForMarker(marker, s.activeTabIndex);
1162
+ let found = await _scanForMarker(marker, s.activeTabIndex);
1163
+ if (found && !found.idx && elsewhere) {
1164
+ for (const win of await elsewhere()) {
1165
+ const there = win !== found.win && await _scanForMarker(marker, s.activeTabIndex, win);
1166
+ if (there?.idx) { found = there; break; }
1167
+ }
1168
+ }
1158
1169
  if (found?.idx) { s.activeTabIndex = found.idx; return { ...found, marker }; }
1159
1170
  // The scan did not complete: keep the marker for the next call, but prove nothing now.
1160
1171
  if (!found) break;
@@ -1780,8 +1791,8 @@ function _foreignTabError(where, cause) {
1780
1791
  // Uses osascriptFast (persistent process, ~5ms) for short scripts,
1781
1792
  // falls back to osascript (~80ms) for long scripts that exceed stdin limits
1782
1793
  // An explicit `tabIndex` names a tab of `win` (`window id N`) when the caller proved it there,
1783
- // else of the target window.
1784
- async function runJS(js, { tabIndex, win, timeout = 15000 } = {}) {
1794
+ // else of the target window. `elsewhere`: see _resolveSessionTab.
1795
+ async function runJS(js, { tabIndex, win, timeout = 15000, elsewhere = null } = {}) {
1785
1796
  await refreshTargetWindow();
1786
1797
  const escaped = js
1787
1798
  .replace(/^\s*\/\/[^\n]*$/gm, '') // Strip // comment-only lines before flattening
@@ -1801,7 +1812,7 @@ async function runJS(js, { tabIndex, win, timeout = 15000 } = {}) {
1801
1812
  // the ~100ms window in which a user tab-shift could leave it pointing at the user's tab.
1802
1813
  idx = _st().activeTabIndex;
1803
1814
  } else if (!idx && (_st().hasOwnedTab || _st().activeTabURL)) {
1804
- const resolved = await _resolveSessionTab();
1815
+ const resolved = await _resolveSessionTab({ elsewhere });
1805
1816
  if (resolved.idx) { idx = resolved.idx; idxWin = resolved.win; _st().lastResolveTime = Date.now(); }
1806
1817
  }
1807
1818
  // A session that never owned a tab falls back to the index it tracks (usually none: the front
@@ -1894,7 +1905,7 @@ async function runJS(js, { tabIndex, win, timeout = 15000 } = {}) {
1894
1905
 
1895
1906
  // Run large JavaScript via temp file — bypasses osascript arg length limit (~260KB)
1896
1907
  // Used for operations that embed file data (upload, paste image)
1897
- async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
1908
+ async function runJSLarge(js, { tabIndex, timeout = 30000, elsewhere = null } = {}) {
1898
1909
  await refreshTargetWindow();
1899
1910
  // Resolve tab the same way runJS does — verify cached index via URL
1900
1911
  let idx = tabIndex;
@@ -1903,7 +1914,7 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
1903
1914
  // the tab is, and skipping the scan meant large-payload ops (upload/paste) could target a
1904
1915
  // stale index.
1905
1916
  if (!idx && (_st().hasOwnedTab || (_st().activeTabURL && _st().activeTabURL !== 'about:blank'))) {
1906
- const resolved = await _resolveSessionTab();
1917
+ const resolved = await _resolveSessionTab({ elsewhere });
1907
1918
  if (resolved.idx) { idx = resolved.idx; idxWin = resolved.win; _st().lastResolveTime = Date.now(); }
1908
1919
  }
1909
1920
  if (!idx) idx = _st().activeTabIndex;
@@ -2594,6 +2605,18 @@ end tell`;
2594
2605
  return { winRef: `window id ${hit[0]}`, winId: Number(hit[0]), tabIndex: idx };
2595
2606
  }
2596
2607
 
2608
+ // An `elsewhere` for runJS/runJSLarge (see _resolveSessionTab): the window `locate()` (index.js
2609
+ // asking the extension for this session's tab) says holds the tab. Asked at most once, and only
2610
+ // when the target window has no tab carrying the marker; no answer leaves the scan as it was.
2611
+ function _locusWindows(locate) {
2612
+ if (!locate) return null;
2613
+ let wins = null;
2614
+ return () => (wins ??= Promise.resolve()
2615
+ .then(locate)
2616
+ .then((locus) => (locus ? _pinWindowFromLocus(locus) : null))
2617
+ .then((pinned) => (pinned ? [pinned.winRef] : []), () => []));
2618
+ }
2619
+
2597
2620
  // Like _withTargetTabFronted, but for an explicit window+tab. Selects the session's tab
2598
2621
  // in ITS OWN window (not whichever window matched the profile name first), runs fn, and
2599
2622
  // restores the previous selection.
@@ -5062,8 +5085,22 @@ function _validateFilePath(filePath) {
5062
5085
 
5063
5086
  // ========== UPLOAD FILE ==========
5064
5087
 
5065
- export async function uploadFile({ selector, filePath, forceNative = false, verifyPreview = false }) {
5088
+ // How many previews of ingested files the page shows: object-URL / data-URL images and videos,
5089
+ // background images, and SVG <image> elements. Google Business Profile draws its preview as
5090
+ // <svg role=img><image href="blob:…">, which went uncounted, so a real upload read as a ghost
5091
+ // pickup and was escalated to a native dialog that added a second copy. -1: the page was unreadable.
5092
+ const _PREVIEW_COUNT_JS = `function(){try{
5093
+ var n=document.querySelectorAll('img[src^="blob:"], img[src^="data:image"], video[src^="blob:"]').length;
5094
+ var all=document.querySelectorAll('div,span,a,figure');
5095
+ for(var i=0;i<all.length;i++){var b=all[i].style&&all[i].style.backgroundImage||'';if(b.indexOf('blob:')>-1||b.indexOf('data:image')>-1)n++;}
5096
+ var svg=document.querySelectorAll('image');
5097
+ for(var j=0;j<svg.length;j++){var h=svg[j].getAttribute('href')||svg[j].getAttributeNS('http://www.w3.org/1999/xlink','href')||'';if(h.indexOf('blob:')===0||h.indexOf('data:image')===0)n++;}
5098
+ return n;}catch(_){return -1;}}`;
5099
+
5100
+ // `locate`: how index.js asks the extension where this session's tab is (see _locusWindows).
5101
+ export async function uploadFile({ selector, filePath, forceNative = false, verifyPreview = false, locate = null }) {
5066
5102
  _validateFilePath(filePath);
5103
+ const elsewhere = _locusWindows(locate);
5067
5104
  // Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
5068
5105
  // NO file dialog, NO System Events, NO focus stealing
5069
5106
 
@@ -5101,7 +5138,8 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
5101
5138
  const imageFormats = new Set(['webp', 'heic', 'heif', 'tiff', 'tif']);
5102
5139
  if (imageFormats.has(ext)) {
5103
5140
  const accept = await runJS(
5104
- `(function(){var el=document.querySelector('${sel}');if(!el){var roots=window.mcpCollectRoots?window.mcpCollectRoots():[document];for(var i=0;i<roots.length;i++){el=roots[i].querySelector('${sel}');if(el)break;}}return el?(el.getAttribute('accept')||''):'';})()`
5141
+ `(function(){var el=document.querySelector('${sel}');if(!el){var roots=window.mcpCollectRoots?window.mcpCollectRoots():[document];for(var i=0;i<roots.length;i++){el=roots[i].querySelector('${sel}');if(el)break;}}return el?(el.getAttribute('accept')||''):'';})()`,
5142
+ { elsewhere }
5105
5143
  ).catch(() => '');
5106
5144
  const acceptStr = String(accept || '').toLowerCase();
5107
5145
  const accepted = !acceptStr ||
@@ -5173,17 +5211,7 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
5173
5211
 
5174
5212
  // Counts the visual proof that a site actually ingested the file: object-URL previews
5175
5213
  // it created for it. Ghost pickups (see Strategy 1) leave this at the pre-upload value.
5176
- function __mcpPreviewCount() {
5177
- try {
5178
- var n = document.querySelectorAll('img[src^="blob:"], img[src^="data:image"], video[src^="blob:"]').length;
5179
- var all = document.querySelectorAll('div,span,a,figure');
5180
- for (var i = 0; i < all.length; i++) {
5181
- var b = all[i].style && all[i].style.backgroundImage || '';
5182
- if (b.indexOf('blob:') > -1 || b.indexOf('data:image') > -1) n++;
5183
- }
5184
- return n;
5185
- } catch (_) { return -1; }
5186
- }
5214
+ var __mcpPreviewCount = ${_PREVIEW_COUNT_JS};
5187
5215
 
5188
5216
  // Baseline BEFORE any dispatch — after the change event a site that works has already
5189
5217
  // painted its preview, so sampling then makes an honest pickup look identical to a ghost.
@@ -5223,7 +5251,7 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
5223
5251
  var hint = newImgs.length > 0 ? ' (detected ' + newImgs.length + ' blob/data images on page — upload likely succeeded)' : '';
5224
5252
  return 'Upload attempted: ${safeName} (' + Math.round(bytes.length / 1024) + ' KB) — drop event dispatched. el.files is empty (normal for custom upload handlers).' + hint + ' Verify with safari_snapshot.';
5225
5253
  })()`,
5226
- { timeout: 30000 }
5254
+ { timeout: 30000, elsewhere }
5227
5255
  );
5228
5256
 
5229
5257
  // Strategy 3: synthetic injection (files=/drop) is silently rejected by isTrusted-gated
@@ -5240,9 +5268,7 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
5240
5268
  if (verifyPreview && !forceNative && /verified [1-9]\d* file/i.test(result)) {
5241
5269
  const before = Number((result.match(/\[previews=(-?\d+)\]/) || [])[1] ?? -1);
5242
5270
  const countPreviews = async () => Number(
5243
- await runJS(
5244
- `(function(){try{var n=document.querySelectorAll('img[src^="blob:"], img[src^="data:image"], video[src^="blob:"]').length;var all=document.querySelectorAll('div,span,a,figure');for(var i=0;i<all.length;i++){var b=all[i].style&&all[i].style.backgroundImage||'';if(b.indexOf('blob:')>-1||b.indexOf('data:image')>-1)n++;}return n;}catch(_){return -1;}})()`
5245
- ).catch(() => -1)
5271
+ await runJS(`(${_PREVIEW_COUNT_JS})()`, { elsewhere }).catch(() => -1)
5246
5272
  );
5247
5273
  // Locked screen: every window is occluded, Safari throttles the page, and the site can take
5248
5274
  // 30–90 s to render the preview (GBP, 26.9.2026) — while the native-dialog escalation cannot
@@ -5368,7 +5394,8 @@ async function _clickInputAndDriveDialog(c, absPath) {
5368
5394
 
5369
5395
  // ========== PASTE IMAGE FROM FILE ==========
5370
5396
 
5371
- export async function pasteImageFromFile({ filePath }) {
5397
+ // `locate`: as for uploadFile.
5398
+ export async function pasteImageFromFile({ filePath, locate = null }) {
5372
5399
  _validateFilePath(filePath);
5373
5400
  // Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
5374
5401
  const { extname } = await import("node:path");
@@ -5413,7 +5440,7 @@ export async function pasteImageFromFile({ filePath }) {
5413
5440
 
5414
5441
  return 'Pasted image: ${fileName} (' + Math.round(bytes.length / 1024) + ' KB)';
5415
5442
  })()`,
5416
- { timeout: 30000 }
5443
+ { timeout: 30000, elsewhere: _locusWindows(locate) }
5417
5444
  );
5418
5445
 
5419
5446
  return result;