safari-mcp 2.22.8 → 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.
- package/index.js +12 -5
- package/package.json +1 -1
- package/safari.js +58 -31
package/index.js
CHANGED
|
@@ -3791,14 +3791,19 @@ server.tool(
|
|
|
3791
3791
|
"safari_upload_file",
|
|
3792
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.",
|
|
3793
3793
|
{
|
|
3794
|
+
receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
|
|
3794
3795
|
selector: z.string().describe("CSS selector of the file input"),
|
|
3795
3796
|
filePath: z.string().describe("Absolute path to the file to upload"),
|
|
3796
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."),
|
|
3797
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."),
|
|
3798
3799
|
},
|
|
3799
3800
|
async (args) => {
|
|
3800
|
-
|
|
3801
|
-
|
|
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 });
|
|
3802
3807
|
return textResult(result);
|
|
3803
3808
|
}
|
|
3804
3809
|
);
|
|
@@ -3809,11 +3814,13 @@ server.tool(
|
|
|
3809
3814
|
"safari_paste_image",
|
|
3810
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.",
|
|
3811
3816
|
{
|
|
3817
|
+
receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
|
|
3812
3818
|
filePath: z.string().describe("Absolute path to the image file (PNG, JPG, WebP)"),
|
|
3813
3819
|
},
|
|
3814
|
-
async ({ filePath }) => {
|
|
3815
|
-
|
|
3816
|
-
|
|
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 });
|
|
3817
3824
|
return textResult(result);
|
|
3818
3825
|
}
|
|
3819
3826
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.22.
|
|
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
|
|
1006
|
-
// { idx, win }: that tab's index, 0 when the scan completed and no tab
|
|
1007
|
-
// the window scanned (see _windowById); or null when the scan could not
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|