safari-mcp 2.19.1 → 2.21.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.
- package/README.md +26 -0
- package/extension/background.js +90 -0
- package/extension/manifest.json +2 -2
- package/index.js +61 -6
- package/ownership-state.js +45 -0
- package/package.json +1 -1
- package/safari.js +9 -0
package/README.md
CHANGED
|
@@ -348,6 +348,31 @@ Prefer stdio (one process per agent) over a persistent daemon? That works too
|
|
|
348
348
|
|
|
349
349
|
---
|
|
350
350
|
|
|
351
|
+
## Acting on a tab you already have open
|
|
352
|
+
|
|
353
|
+
By default the server touches only tabs it opened itself. Point it at one of yours and it refuses:
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
Tab safety: refusing "click" — current tab (https://mail.example.com/inbox) was not
|
|
357
|
+
opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
That default exists because early versions did click into and close people's tabs. But "read the article I'm looking at" and "fill in the form on my screen" are real, and reopening the page loses the session state that made your tab worth using. Set `SAFARI_MCP_ALLOW_USER_TABS=1` and an **explicit** `safari_switch_tab` adopts the tab instead of refusing it; from then on the session works in it like one of its own, and says so:
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{ "tabIndex": 3, "safeUrl": "https://mail.example.com/inbox", "note": "(user tab, opted-in)" }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
What the flag deliberately does *not* do:
|
|
367
|
+
|
|
368
|
+
- **It unlocks adoption, not the guards.** Only `safari_switch_tab` adopts, and only the tab you named. An ordinary click or navigate still never lands on whatever tab happens to be in front — the server acts on the tab you pointed it at, not the one you wandered to.
|
|
369
|
+
- **`safari_close_tab` still refuses.** Closing is the one action whose cost you cannot undo, so an adopted tab is writable, never disposable. Close it yourself.
|
|
370
|
+
- **Adoption is session-local.** Nothing is written to the shared ownership file, so it ends with the session rather than leaking to the next process on the machine.
|
|
371
|
+
|
|
372
|
+
`safari_doctor` prints the flag's state, and every operation on an adopted tab logs `(user tab, opted-in)` — so "why did it touch my tab" has an answer instead of being a mystery. Default off; set it only for agents you want working inside your own browsing session. Designed in [#92](https://github.com/achiya-automation/safari-mcp/issues/92).
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
351
376
|
## Environment variables
|
|
352
377
|
|
|
353
378
|
| Variable | Default | What it does |
|
|
@@ -355,6 +380,7 @@ Prefer stdio (one process per agent) over a persistent daemon? That works too
|
|
|
355
380
|
| `SAFARI_MCP_HTTP` | off | Run one shared HTTP daemon instead of a process per client (see above). |
|
|
356
381
|
| `SAFARI_MCP_HTTP_PORT` | `9225` | Port for that daemon. |
|
|
357
382
|
| `SAFARI_PROFILE` | unset | Bind sessions to a named Safari profile. Unset = your ordinary windows. |
|
|
383
|
+
| `SAFARI_MCP_ALLOW_USER_TABS` | off | Let `safari_switch_tab` adopt a tab **you** already had open, instead of refusing it (see below). |
|
|
358
384
|
| `SAFARI_MCP_RAISE_ON_NAVIGATE` | off | Let navigation bring Safari to the front, and stop the focus guard from putting your previous app back. |
|
|
359
385
|
| `SAFARI_MCP_SCREENSHOT_MAX_WIDTH` | unset | Downscale every `safari_screenshot` to this pixel width (Retina captures are 2× the viewport). Per-call `maxWidth` overrides it. |
|
|
360
386
|
| `SAFARI_MCP_KEEPALIVE_TAB` | off | Keep one daemon-served page open in the profile window so Safari never parks the extension worker between commands. |
|
package/extension/background.js
CHANGED
|
@@ -1116,6 +1116,10 @@ async function handleCommand(type, payload) {
|
|
|
1116
1116
|
// --- JavaScript Execution — multi-strategy to handle CSP restrictions ---
|
|
1117
1117
|
// Strategy 1: indirect eval (fast, works when CSP allows unsafe-eval)
|
|
1118
1118
|
// Strategy 2: script element injection (bypasses CSP in MAIN world context)
|
|
1119
|
+
case "list_frames": {
|
|
1120
|
+
return await listFrames(tabId);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1119
1123
|
case "evaluate": {
|
|
1120
1124
|
// Strategy 0: pages that stall injection outright. Every strategy below reaches
|
|
1121
1125
|
// the page through scripting.executeScript, which on business.facebook.com never
|
|
@@ -1124,6 +1128,12 @@ async function handleCommand(type, payload) {
|
|
|
1124
1128
|
// it FIRST once a tab is known to block injection, and fall back to it below
|
|
1125
1129
|
// when a fresh tab turns out to block it too.
|
|
1126
1130
|
const evalTabId = tabId || (await getActiveTab()).id;
|
|
1131
|
+
// A frame selector short-circuits every strategy below: those all target the
|
|
1132
|
+
// main frame, which is exactly what makes an embedded app unreachable.
|
|
1133
|
+
if (payload.frame !== undefined && payload.frame !== null && payload.frame !== "") {
|
|
1134
|
+
const _fid = await resolveFrameId(evalTabId, payload.frame);
|
|
1135
|
+
return await evaluateInFrame(evalTabId, _fid, payload.script);
|
|
1136
|
+
}
|
|
1127
1137
|
const viaBridge = async () => {
|
|
1128
1138
|
const r = await sendContentCommand(
|
|
1129
1139
|
evalTabId, "mcp-content-eval", { source: payload.script }, 10000
|
|
@@ -4170,6 +4180,86 @@ function _isFrameMiss(value) {
|
|
|
4170
4180
|
// Execute in ALL frames (including cross-origin iframes) and return the first real
|
|
4171
4181
|
// match. A top-frame `Element not found` is a semantic miss, not a result: allowing
|
|
4172
4182
|
// it to win used to hide valid matches in every child frame behind it.
|
|
4183
|
+
// --- Frame targeting -------------------------------------------------------
|
|
4184
|
+
// Every other command reaches only the tab's main frame, so a cross-origin
|
|
4185
|
+
// iframe — a micro-frontend app shell, an embedded checkout, GoHighLevel's
|
|
4186
|
+
// workflow builder — was effectively invisible: read_page returned the outer
|
|
4187
|
+
// shell's loader text and evaluate ran outside the app entirely. Safari's
|
|
4188
|
+
// scripting.executeScript can address one frame by id, so these expose the
|
|
4189
|
+
// frame list and let a script run inside a chosen frame.
|
|
4190
|
+
async function listFrames(tabId = null) {
|
|
4191
|
+
const id = tabId || (await getActiveTab()).id;
|
|
4192
|
+
const results = await _executeAllFrames(() => ({
|
|
4193
|
+
url: location.href,
|
|
4194
|
+
title: document.title,
|
|
4195
|
+
textLength: ((document.body && document.body.innerText) || "").length,
|
|
4196
|
+
}), [], id);
|
|
4197
|
+
return results.map((r, i) => (
|
|
4198
|
+
r && r.error
|
|
4199
|
+
? { index: i, frameId: Number.isInteger(r.frameId) ? r.frameId : null, error: String(r.error) }
|
|
4200
|
+
: { index: i, frameId: Number.isInteger(r && r.frameId) ? r.frameId : null, ...((r && r.result) || {}) }
|
|
4201
|
+
));
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
// Resolve a caller's frame selector to one concrete frameId. `frame` is either a
|
|
4205
|
+
// numeric frameId or a substring matched against each frame's URL. Ambiguity is
|
|
4206
|
+
// an error rather than a guess: silently picking one of several matching frames
|
|
4207
|
+
// is how an automation ends up acting on the wrong document.
|
|
4208
|
+
async function resolveFrameId(tabId, frame) {
|
|
4209
|
+
if (typeof frame === "number" && Number.isInteger(frame)) return frame;
|
|
4210
|
+
const needle = String(frame == null ? "" : frame).toLowerCase();
|
|
4211
|
+
if (!needle) throw new Error("frame must be a frameId number or a URL substring");
|
|
4212
|
+
const frames = await listFrames(tabId);
|
|
4213
|
+
const hits = frames.filter((f) => !f.error && typeof f.url === "string" && f.url.toLowerCase().includes(needle));
|
|
4214
|
+
if (hits.length === 0) {
|
|
4215
|
+
throw new Error("No frame matched " + JSON.stringify(String(frame)) + ". Frames present: " +
|
|
4216
|
+
(frames.map((f) => f.url || ("#" + f.index)).join(" | ") || "(none)"));
|
|
4217
|
+
}
|
|
4218
|
+
if (hits.length > 1) {
|
|
4219
|
+
throw new Error("Frame selector " + JSON.stringify(String(frame)) + " matched " + hits.length +
|
|
4220
|
+
" frames — narrow it: " + hits.map((f) => f.url).join(" | "));
|
|
4221
|
+
}
|
|
4222
|
+
if (!Number.isInteger(hits[0].frameId)) throw new Error("Matched frame has no stable frameId");
|
|
4223
|
+
return hits[0].frameId;
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
// Run one script inside a single frame. MAIN world first so the script sees the
|
|
4227
|
+
// page's own globals; ISOLATED as the fallback for frames whose CSP blocks eval —
|
|
4228
|
+
// it still reads and mutates the DOM, which is what clicking and filling need.
|
|
4229
|
+
async function evaluateInFrame(tabId, frameId, script) {
|
|
4230
|
+
const id = tabId || (await getActiveTab()).id;
|
|
4231
|
+
const runner = async (src) => {
|
|
4232
|
+
try {
|
|
4233
|
+
const result = await (0, eval)(src);
|
|
4234
|
+
if (result === undefined || result === null) return null;
|
|
4235
|
+
return typeof result === "object" ? JSON.stringify(result) : String(result);
|
|
4236
|
+
} catch (e) {
|
|
4237
|
+
const m = String(e && e.message);
|
|
4238
|
+
if (m.includes("unsafe-eval") || m.includes("trusted-types") || m.includes("Trusted Type")) {
|
|
4239
|
+
return "__CSP_BLOCKED__";
|
|
4240
|
+
}
|
|
4241
|
+
return "Error: " + m;
|
|
4242
|
+
}
|
|
4243
|
+
};
|
|
4244
|
+
const run = async (world) => {
|
|
4245
|
+
const results = await _withInjectionDeadline(browser.scripting.executeScript({
|
|
4246
|
+
target: { tabId: id, frameIds: [frameId] },
|
|
4247
|
+
world,
|
|
4248
|
+
func: runner,
|
|
4249
|
+
args: [script],
|
|
4250
|
+
}));
|
|
4251
|
+
const first = results[0];
|
|
4252
|
+
if (first && first.error) throw new Error(first.error);
|
|
4253
|
+
return first && first.result;
|
|
4254
|
+
};
|
|
4255
|
+
const mainResult = await run("MAIN");
|
|
4256
|
+
if (mainResult !== "__CSP_BLOCKED__") return mainResult;
|
|
4257
|
+
const isolated = await run("ISOLATED");
|
|
4258
|
+
return isolated === "__CSP_BLOCKED__"
|
|
4259
|
+
? "Error: eval is blocked by CSP in both MAIN and ISOLATED worlds for this frame"
|
|
4260
|
+
: isolated;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4173
4263
|
async function execInAllFrames(func, args = [], tabId = null) {
|
|
4174
4264
|
try {
|
|
4175
4265
|
const results = await _executeAllFrames(func, args, tabId);
|
package/extension/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Safari MCP Bridge",
|
|
4
4
|
"description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
|
|
5
|
-
"version": "2.10.
|
|
5
|
+
"version": "2.10.11",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
|
@@ -65,4 +65,4 @@
|
|
|
65
65
|
"run_at": "document_start"
|
|
66
66
|
}
|
|
67
67
|
]
|
|
68
|
-
}
|
|
68
|
+
}
|
package/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
OWNERSHIP_DIR, BLANK_TAB_SENTINEL,
|
|
17
17
|
_openedTabs, _ownedTabURLs,
|
|
18
18
|
_isURLOwned, _markBlankTabOpened, _addOwnedURL, _removeOwnedURL, _trackTab, _untrackTab,
|
|
19
|
+
allowUserTabs, _adoptUserTab, _isAdoptedURL,
|
|
19
20
|
} from "./ownership-state.js";
|
|
20
21
|
import { WebSocketServer } from "ws";
|
|
21
22
|
import { createServer } from "node:http";
|
|
@@ -1445,6 +1446,7 @@ const _noOwnershipCheck = new Set([
|
|
|
1445
1446
|
"reload_extension",
|
|
1446
1447
|
// Read-only — don't modify the page
|
|
1447
1448
|
"read_page", "get_source", "snapshot", "accessibility_snapshot",
|
|
1449
|
+
"list_frames",
|
|
1448
1450
|
"get_element", "query_all", "screenshot", "screenshot_element",
|
|
1449
1451
|
"get_console", "list_console_messages", "start_console",
|
|
1450
1452
|
"get_network", "list_network_requests", "start_network_capture",
|
|
@@ -1865,11 +1867,24 @@ async function _runExtensionBatchAction(action, args = {}) {
|
|
|
1865
1867
|
// an owned tab — this is what prevents navigating/clicking in the user's tabs.
|
|
1866
1868
|
function _assertTabOwnership(opType, extensionPayload = {}) {
|
|
1867
1869
|
if (_noOwnershipCheck.has(opType)) return;
|
|
1870
|
+
// Closing is the one op the opt-in never unlocks (#92, condition 2). Adoption makes a
|
|
1871
|
+
// user's tab writable, not disposable — a wrong close costs work that cannot be undone
|
|
1872
|
+
// (#68). Checked before every early return so the batch action and the tool share it.
|
|
1873
|
+
if (opType === "close_tab" && _isAdoptedURL(safari.getActiveTabURL())) {
|
|
1874
|
+
const msg = `⚠️ Tab safety: refusing "close_tab" — this tab was adopted from you via SAFARI_MCP_ALLOW_USER_TABS, not opened by this MCP session. Close it yourself, or open your own tab with safari_new_tab.`;
|
|
1875
|
+
console.error(`[Safari MCP] ${msg}`);
|
|
1876
|
+
throw new Error(msg);
|
|
1877
|
+
}
|
|
1868
1878
|
// In a named profile the extension is the authority. A bearer receipt may be
|
|
1869
1879
|
// presented after a stateless reconnect; only the extension can validate its exact
|
|
1870
1880
|
// tab binding, freshness, digest, and origin.
|
|
1871
1881
|
if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
|
|
1872
1882
|
const currentUrl = safari.getActiveTabURL();
|
|
1883
|
+
// An adopted tab (#92) is the session's target even though the session opened nothing.
|
|
1884
|
+
if (_isAdoptedURL(currentUrl)) {
|
|
1885
|
+
console.error(`[Safari MCP] "${opType}" on ${_safeUrlForOutput(currentUrl)} (user tab, opted-in)`);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1873
1888
|
if (_ownedTabURLs.size === 0 && _openedTabs.size === 0) {
|
|
1874
1889
|
// No tabs opened yet — block everything except read-only ops
|
|
1875
1890
|
const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${opType}".`;
|
|
@@ -2780,7 +2795,12 @@ server.tool(
|
|
|
2780
2795
|
}
|
|
2781
2796
|
|
|
2782
2797
|
// Tab ownership check: verify target tab is one we opened
|
|
2783
|
-
|
|
2798
|
+
let adopted = false;
|
|
2799
|
+
// `_ownedTabURLs.size > 0` alone skipped this whole lookup for a session that had opened
|
|
2800
|
+
// nothing — fine while switch_tab could only reach owned tabs, but adoption (#92) has to
|
|
2801
|
+
// work from a cold session, which is precisely the "read the article in my current tab"
|
|
2802
|
+
// case the opt-in exists for.
|
|
2803
|
+
if (!process.env.SAFARI_PROFILE && (_ownedTabURLs.size > 0 || allowUserTabs())) {
|
|
2784
2804
|
// Get target tab's URL via list_tabs before switching
|
|
2785
2805
|
try {
|
|
2786
2806
|
const tabs = await extensionOrFallback(
|
|
@@ -2804,8 +2824,16 @@ server.tool(
|
|
|
2804
2824
|
// fallback (which has no ownership check of its own) stays guarded.
|
|
2805
2825
|
const trackedOrigin = _originOf(_openedTabs.get(index)?.url);
|
|
2806
2826
|
const isTrackedRedirect = !!trackedOrigin && trackedOrigin === _originOf(target.url);
|
|
2807
|
-
if (!isBlankOwned && !isTrackedRedirect) {
|
|
2808
|
-
|
|
2827
|
+
if (!isBlankOwned && !isTrackedRedirect && allowUserTabs()) {
|
|
2828
|
+
// The opt-in turns this refusal into a deliberate, named adoption (#92). Both
|
|
2829
|
+
// spellings of the URL go in: the tool layer hands callers the query-stripped
|
|
2830
|
+
// form and later compares against it, while list_tabs reported the raw one.
|
|
2831
|
+
_adoptUserTab(target.url);
|
|
2832
|
+
_adoptUserTab(_safeUrlForOutput(target.url));
|
|
2833
|
+
adopted = true;
|
|
2834
|
+
console.error(`[Safari MCP] switch_tab adopted ${_safeUrlForOutput(target.url)} (user tab, opted-in via SAFARI_MCP_ALLOW_USER_TABS)`);
|
|
2835
|
+
} else if (!isBlankOwned && !isTrackedRedirect) {
|
|
2836
|
+
const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${_safeUrlForOutput(target.url)}) — not opened by this MCP session. Use safari_new_tab to open your own tab, or set SAFARI_MCP_ALLOW_USER_TABS=1 to let switch_tab adopt a tab you already had open.`;
|
|
2809
2837
|
console.error(`[Safari MCP] ${msg}`);
|
|
2810
2838
|
return errorResult(msg);
|
|
2811
2839
|
}
|
|
@@ -2822,7 +2850,10 @@ server.tool(
|
|
|
2822
2850
|
if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
|
|
2823
2851
|
if (safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
|
|
2824
2852
|
if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
|
|
2825
|
-
|
|
2853
|
+
// Say so in the result, not only in the log: an agent that adopted a user's tab should
|
|
2854
|
+
// be able to see that from the answer it got (#92, condition 3).
|
|
2855
|
+
const reported = adopted ? { ...safeResult, note: "(user tab, opted-in)" } : safeResult;
|
|
2856
|
+
return { content: [{ type: "text", text: JSON.stringify(reported) }] };
|
|
2826
2857
|
}
|
|
2827
2858
|
);
|
|
2828
2859
|
|
|
@@ -2906,6 +2937,23 @@ server.tool(
|
|
|
2906
2937
|
}
|
|
2907
2938
|
);
|
|
2908
2939
|
|
|
2940
|
+
// ========== FRAMES ==========
|
|
2941
|
+
|
|
2942
|
+
server.tool(
|
|
2943
|
+
"safari_list_frames",
|
|
2944
|
+
"List every document in the tab — the main page plus each iframe — with its frameId, URL and text length. Use when a page's content lives in a cross-origin iframe (an embedded app, a micro-frontend shell, a payment field): safari_read_page and safari_evaluate see only the main document there and come back empty or with a loader. Pass the frameId or a URL substring as safari_evaluate's `frame` to run inside it.",
|
|
2945
|
+
{
|
|
2946
|
+
receipt: z.string().optional().describe("Opaque extension-issued tab receipt"),
|
|
2947
|
+
},
|
|
2948
|
+
async (args) => {
|
|
2949
|
+
const result = await extensionOrFallback(
|
|
2950
|
+
"list_frames", { ..._explicitReceipt(args) },
|
|
2951
|
+
() => { throw new Error("safari_list_frames needs the extension — AppleScript cannot enumerate frames. Check safari_doctor."); }
|
|
2952
|
+
);
|
|
2953
|
+
return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] };
|
|
2954
|
+
}
|
|
2955
|
+
);
|
|
2956
|
+
|
|
2909
2957
|
// ========== EVALUATE JAVASCRIPT ==========
|
|
2910
2958
|
|
|
2911
2959
|
server.tool(
|
|
@@ -2913,12 +2961,19 @@ server.tool(
|
|
|
2913
2961
|
"Execute JavaScript in the current page (a returned Promise is awaited — fetch/timers work in background tabs; requestAnimationFrame never fires there). Automatically falls back to AppleScript when CSP blocks execution (e.g. Google Search Console, LinkedIn). For reading data, prefer safari_read_page or safari_snapshot. For interactions, prefer safari_click/fill with refs.",
|
|
2914
2962
|
{
|
|
2915
2963
|
script: z.string().describe("JavaScript code to execute"),
|
|
2964
|
+
frame: z.union([z.string(), z.number()]).optional().describe("Run inside a sub-frame instead of the main document: a frameId from safari_list_frames, or a substring of the frame's URL (must match exactly one). Needed for cross-origin iframes — micro-frontend app shells, embedded checkouts — where the main document only holds a loader."),
|
|
2916
2965
|
receipt: z.string().optional().describe("Opaque extension-issued tab receipt — pass the one safari_new_tab returned to keep targeting that tab after an MCP reconnect"),
|
|
2917
2966
|
},
|
|
2918
2967
|
async (args) => {
|
|
2919
2968
|
const result = await extensionOrFallback(
|
|
2920
|
-
"evaluate",
|
|
2921
|
-
(
|
|
2969
|
+
"evaluate",
|
|
2970
|
+
{ script: args.script, ...(args.frame === undefined ? {} : { frame: args.frame }), ..._explicitReceipt(args) },
|
|
2971
|
+
() => {
|
|
2972
|
+
if (args.frame !== undefined) {
|
|
2973
|
+
throw new Error("safari_evaluate: frame targeting needs the extension — AppleScript reaches only the main document. Check the extension is connected (safari_doctor).");
|
|
2974
|
+
}
|
|
2975
|
+
return safari.evaluate(args);
|
|
2976
|
+
}
|
|
2922
2977
|
);
|
|
2923
2978
|
return { content: [{ type: "text", text: (typeof result === 'string' ? result : JSON.stringify(result)) || "(no return value)" }] };
|
|
2924
2979
|
}
|
package/ownership-state.js
CHANGED
|
@@ -62,6 +62,10 @@ export function _saveOwnershipFile(urls, removed = []) {
|
|
|
62
62
|
const mergedTs = new Map();
|
|
63
63
|
for (const e of _loadOwnershipFile()) mergedTs.set(e.url, e.ts);
|
|
64
64
|
for (const url of urls) {
|
|
65
|
+
// Adopted user tabs (#92) are session-local by construction — see _adoptUserTab. This
|
|
66
|
+
// is the one place that writes the file, so the exclusion belongs here rather than at
|
|
67
|
+
// each caller: _pruneExpiredOwnership() also saves, and it does not know about adoption.
|
|
68
|
+
if (_adoptedTabURLs.has(url)) continue;
|
|
65
69
|
const localTs = _ownedTabTimestamps.get(url) ?? now;
|
|
66
70
|
mergedTs.set(url, Math.max(localTs, mergedTs.get(url) ?? 0));
|
|
67
71
|
}
|
|
@@ -148,6 +152,47 @@ export function _markBlankTabOpened() {
|
|
|
148
152
|
}
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
// ========== OPT-IN TAB ADOPTION (#92) ==========
|
|
156
|
+
// With SAFARI_MCP_ALLOW_USER_TABS set, an EXPLICIT safari_switch_tab may adopt a tab the
|
|
157
|
+
// user already had open, and the session then acts on it like one of its own. The flag
|
|
158
|
+
// deliberately unlocks adoption rather than blanket-skipping the guards: "the server acts
|
|
159
|
+
// on the tab you pointed it at, not the one you wandered to" is the property that makes the
|
|
160
|
+
// opt-in safe, and an ambient op landing on whatever tab is frontmost is the exact failure
|
|
161
|
+
// the guards were built against. Read from the environment on each call so a host can flip
|
|
162
|
+
// it without a restart and so tests need no module reload.
|
|
163
|
+
export function allowUserTabs() {
|
|
164
|
+
const v = String(process.env.SAFARI_MCP_ALLOW_USER_TABS || "").trim().toLowerCase();
|
|
165
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// URLs adopted from the user, as opposed to opened by this session. Two rules ride on this
|
|
169
|
+
// set: closing is refused for every member (#68 — a wrong close costs the user work that a
|
|
170
|
+
// wrong read never does), and nothing in it is ever written to the shared ownership file.
|
|
171
|
+
export const _adoptedTabURLs = new Set();
|
|
172
|
+
|
|
173
|
+
// Adoption is in-memory ONLY. owned-tabs.json is shared by every safari-mcp process on the
|
|
174
|
+
// machine and outlives this session, so a persisted adoption would hand the user's tab to
|
|
175
|
+
// the next process — one that has no _adoptedTabURLs entry and would therefore let
|
|
176
|
+
// close_tab through. Session-local is also the honest lifetime: the opt-in is "act on the
|
|
177
|
+
// tab I pointed you at", not "own it from now on".
|
|
178
|
+
export function _adoptUserTab(url) {
|
|
179
|
+
if (!allowUserTabs()) return false;
|
|
180
|
+
if (!url || url === "about:blank" || url === "missing value" || url === "favorites://") return false;
|
|
181
|
+
_adoptedTabURLs.add(url);
|
|
182
|
+
if (!_ownedTabTimestamps.has(url)) _ownedTabTimestamps.set(url, Date.now());
|
|
183
|
+
_ownedTabURLs.add(url);
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function _isAdoptedURL(url) {
|
|
188
|
+
if (!url) return false;
|
|
189
|
+
if (_adoptedTabURLs.has(url)) return true;
|
|
190
|
+
// The caller may hold the query-stripped form of the URL (origin+pathname) that the tool
|
|
191
|
+
// layer hands back, while adoption recorded the raw one, or the reverse. Both name the
|
|
192
|
+
// same adopted document, and only a refusal hangs off this answer.
|
|
193
|
+
return findOwnedMatch(url, _adoptedTabURLs) !== null;
|
|
194
|
+
}
|
|
195
|
+
|
|
151
196
|
export function _addOwnedURL(url) {
|
|
152
197
|
if (url && url !== "about:blank" && url !== "favorites://") {
|
|
153
198
|
if (!_ownedTabTimestamps.has(url)) _ownedTabTimestamps.set(url, Date.now());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.0",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -14,6 +14,7 @@ import { randomUUID } from "node:crypto";
|
|
|
14
14
|
import { VIEWPORT_SCRIPT, SAFE_AREA_SCRIPT, PWA_SCRIPT, WEBKIT_COMPAT_SCRIPT } from "./injected-validators.js";
|
|
15
15
|
import { escJsSingleQuote, escAppleScriptString } from "./injected-escape.js";
|
|
16
16
|
import { currentSessionId } from "./session-context.js";
|
|
17
|
+
import { allowUserTabs } from "./ownership-state.js";
|
|
17
18
|
// Extension bridge is handled by index.js (WebSocket server on port 9223)
|
|
18
19
|
|
|
19
20
|
const execFileAsync = promisify(execFile);
|
|
@@ -5781,6 +5782,14 @@ export async function doctor() {
|
|
|
5781
5782
|
const passed = checks.filter((c) => c.ok).length;
|
|
5782
5783
|
const lines = [`Safari MCP doctor — ${passed}/${checks.length} checks passed`, ""];
|
|
5783
5784
|
if (osLine) lines.push(osLine, "");
|
|
5785
|
+
// Not a pass/fail check — a state the user has to be able to see here rather than dig out
|
|
5786
|
+
// of the host's env, so "why did it touch my tab" has an answer in the same report (#92).
|
|
5787
|
+
lines.push(
|
|
5788
|
+
allowUserTabs()
|
|
5789
|
+
? "ℹ️ Tab adoption (SAFARI_MCP_ALLOW_USER_TABS): ON — safari_switch_tab may adopt a tab you already had open. safari_close_tab still refuses an adopted tab."
|
|
5790
|
+
: "ℹ️ Tab adoption (SAFARI_MCP_ALLOW_USER_TABS): off (default) — the session acts only on tabs it opened itself.",
|
|
5791
|
+
"",
|
|
5792
|
+
);
|
|
5784
5793
|
for (const c of checks) {
|
|
5785
5794
|
lines.push(`${c.ok ? "✅" : "❌"} ${c.label}: ${c.detail}`);
|
|
5786
5795
|
if (!c.ok && c.fix) lines.push(` → ${c.fix}`);
|