safari-mcp 2.22.8 → 2.22.10

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/index.js CHANGED
@@ -37,6 +37,12 @@ import {
37
37
  readProfileIdentities,
38
38
  describeProfileIdentities,
39
39
  } from "./worker-ledger.js";
40
+ import { Socket } from "node:net";
41
+ import { ignoreTypeOfServiceEinval } from "./node-compat.js";
42
+
43
+ // Before the first fetch: a secondary's /proxy-check to a primary that just went away
44
+ // must reject, not end the process (#140).
45
+ ignoreTypeOfServiceEinval(Socket.prototype);
40
46
 
41
47
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS
42
48
 
@@ -3791,14 +3797,19 @@ server.tool(
3791
3797
  "safari_upload_file",
3792
3798
  "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.",
3793
3799
  {
3800
+ receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
3794
3801
  selector: z.string().describe("CSS selector of the file input"),
3795
3802
  filePath: z.string().describe("Absolute path to the file to upload"),
3796
3803
  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."),
3797
3804
  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."),
3798
3805
  },
3799
3806
  async (args) => {
3800
- _assertTabOwnership("upload_file");
3801
- const result = await safari.uploadFile(args);
3807
+ // The receipt first: for a caller with no state of its own it is what names the tab, and the
3808
+ // guard has to see it. The upload bypasses extensionOrFallback, so assert ownership here.
3809
+ _assertTabOwnership("upload_file", _explicitReceipt(args));
3810
+ // AppleScript carries the file; it finds the tab by its marker in the profile's first window,
3811
+ // and the extension can say which of the profile's windows holds it (see _locusWindows).
3812
+ const result = await safari.uploadFile({ ...args, locate: _tabLocusFromExtension });
3802
3813
  return textResult(result);
3803
3814
  }
3804
3815
  );
@@ -3809,11 +3820,13 @@ server.tool(
3809
3820
  "safari_paste_image",
3810
3821
  "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.",
3811
3822
  {
3823
+ receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
3812
3824
  filePath: z.string().describe("Absolute path to the image file (PNG, JPG, WebP)"),
3813
3825
  },
3814
- async ({ filePath }) => {
3815
- _assertTabOwnership("paste_image");
3816
- const result = await safari.pasteImageFromFile({ filePath });
3826
+ async ({ filePath, receipt }) => {
3827
+ // As safari_upload_file: the receipt before the guard, and the extension's locus for the scan.
3828
+ _assertTabOwnership("paste_image", _explicitReceipt({ receipt }));
3829
+ const result = await safari.pasteImageFromFile({ filePath, locate: _tabLocusFromExtension });
3817
3830
  return textResult(result);
3818
3831
  }
3819
3832
  );
package/node-compat.js ADDED
@@ -0,0 +1,22 @@
1
+ // Node 24's bundled undici (7.x) calls socket.setTypeOfService() on every HTTP/1.1
2
+ // write without a catch. On macOS, a socket whose peer has just reset makes that call
3
+ // throw EINVAL from inside an I/O callback, where no try/catch around fetch() can reach
4
+ // it, so the whole process exits (#140: a secondary died this way exactly when its
5
+ // primary went away, instead of taking the bridge port over). undici 8.8.0 ignores
6
+ // these errors (nodejs/undici#5547, Node 26.5.1+); Node 24 never got the fix.
7
+ // Swallowing only EINVAL leaves the request to fail the ordinary way, as a rejected
8
+ // fetch. Type of service is a QoS hint, so skipping it changes nothing else.
9
+ export function ignoreTypeOfServiceEinval(proto) {
10
+ const original = proto.setTypeOfService;
11
+ if (typeof original !== "function" || original.ignoresEinval) return;
12
+ const wrapped = function (...args) {
13
+ try {
14
+ return original.apply(this, args);
15
+ } catch (err) {
16
+ if (err?.code === "EINVAL") return this;
17
+ throw err;
18
+ }
19
+ };
20
+ wrapped.ignoresEinval = true;
21
+ proto.setTypeOfService = wrapped;
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.22.8",
3
+ "version": "2.22.10",
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",
@@ -71,6 +71,7 @@
71
71
  "ownership-state.js",
72
72
  "image-size.js",
73
73
  "worker-ledger.js",
74
+ "node-compat.js",
74
75
  "mcp-helpers.js",
75
76
  "injected-validators.js",
76
77
  "injected-escape.js",
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;