safari-mcp 2.20.0 → 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 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/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";
@@ -1866,11 +1867,24 @@ async function _runExtensionBatchAction(action, args = {}) {
1866
1867
  // an owned tab — this is what prevents navigating/clicking in the user's tabs.
1867
1868
  function _assertTabOwnership(opType, extensionPayload = {}) {
1868
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
+ }
1869
1878
  // In a named profile the extension is the authority. A bearer receipt may be
1870
1879
  // presented after a stateless reconnect; only the extension can validate its exact
1871
1880
  // tab binding, freshness, digest, and origin.
1872
1881
  if (_preferAppleScript && _receiptToken(extensionPayload.receipt || _getActiveReceipt())) return;
1873
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
+ }
1874
1888
  if (_ownedTabURLs.size === 0 && _openedTabs.size === 0) {
1875
1889
  // No tabs opened yet — block everything except read-only ops
1876
1890
  const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${opType}".`;
@@ -2781,7 +2795,12 @@ server.tool(
2781
2795
  }
2782
2796
 
2783
2797
  // Tab ownership check: verify target tab is one we opened
2784
- if (!process.env.SAFARI_PROFILE && _ownedTabURLs.size > 0) {
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())) {
2785
2804
  // Get target tab's URL via list_tabs before switching
2786
2805
  try {
2787
2806
  const tabs = await extensionOrFallback(
@@ -2805,8 +2824,16 @@ server.tool(
2805
2824
  // fallback (which has no ownership check of its own) stays guarded.
2806
2825
  const trackedOrigin = _originOf(_openedTabs.get(index)?.url);
2807
2826
  const isTrackedRedirect = !!trackedOrigin && trackedOrigin === _originOf(target.url);
2808
- if (!isBlankOwned && !isTrackedRedirect) {
2809
- 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.`;
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.`;
2810
2837
  console.error(`[Safari MCP] ${msg}`);
2811
2838
  return errorResult(msg);
2812
2839
  }
@@ -2823,7 +2850,10 @@ server.tool(
2823
2850
  if (resolvedIndex) safari.setActiveTabIndex(resolvedIndex);
2824
2851
  if (safeResult?.safeUrl) safari.setActiveTabURL(safeResult.safeUrl);
2825
2852
  if (safeResult?.receipt || token) _setActiveReceipt(safeResult?.receipt || token);
2826
- return { content: [{ type: "text", text: JSON.stringify(safeResult) }] };
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) }] };
2827
2857
  }
2828
2858
  );
2829
2859
 
@@ -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.20.0",
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}`);