safari-mcp 2.14.0 → 2.15.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
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="social-preview.png" alt="Safari MCP Server — 80 native browser automation tools for AI agents on macOS" width="100%">
3
+ <img src="social-preview.png" alt="Safari MCP Server — 96 native browser automation tools for AI agents on macOS" width="100%">
4
4
 
5
5
  <br/>
6
6
 
@@ -24,9 +24,9 @@
24
24
  <a href="https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
25
25
  <a href="cursor://anysphere.cursor-deeplink/mcp/install?name=safari-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D"><img src="https://img.shields.io/badge/Cursor-Install_Server-f97316?logo=cursor&logoColor=white" alt="Install in Cursor"></a>
26
26
 
27
- **80 tools** · **No Chrome/Puppeteer/Playwright needed** · **~5ms per command** · **60% less CPU than Chrome**
27
+ **96 tools** · **No Chrome/Puppeteer/Playwright needed** · **~5ms per command** · **60% less CPU than Chrome**
28
28
 
29
- [Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Examples](examples/) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture) · [Changelog](CHANGELOG.md)
29
+ [Quick Start](#quick-start) · [All 96 Tools](#tools-96) · [Examples](examples/) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture) · [Changelog](CHANGELOG.md)
30
30
 
31
31
  ![Safari MCP Demo](https://github.com/achiya-automation/safari-mcp/raw/main/assets/safari-mcp-promo.gif)
32
32
 
@@ -44,15 +44,17 @@ Your AI agent needs to browse. So it either:
44
44
 
45
45
  Your AI drives the **Safari you're already logged into** — Gmail, GitHub, Ahrefs, Slack, banking.
46
46
 
47
- Native WebKit. ~60% less CPU. Background operation. 80 tools. One `npx` command. macOS only.
47
+ Native WebKit. ~60% less CPU. Background operation. 96 tools. One `npx` command. macOS only.
48
48
 
49
49
  > 📰 **Featured on freeCodeCamp:** [How to Connect Your AI Coding Agent to a Browser on macOS](https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/) · [HackerNoon: Reverse-Engineering React, Shadow DOM, and CSP](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
50
50
 
51
+ > 🍎 **Apple shipped an official Safari MCP** (Safari Technology Preview 247, July 2026). It's built on `safaridriver` for isolated debugging sessions. safari-mcp drives the **real Safari you're already logged into** — on stable Safari, with 96 tools. See the full comparison below.
52
+
51
53
  ---
52
54
 
53
55
  ## Highlights
54
56
 
55
- - **80 tools** — navigation, clicks, forms, screenshots, network, storage, accessibility, and more
57
+ - **96 tools** — navigation, clicks, forms, screenshots, network, storage, accessibility, and more
56
58
  - **Zero heat** — native WebKit on Apple Silicon, ~60% less CPU than Chrome
57
59
  - **Your real browser** — keeps all logins, cookies, sessions (Gmail, GitHub, Ahrefs, etc.)
58
60
  - **Background operation** — Safari stays in the background, no window stealing
@@ -302,7 +304,7 @@ The recommended pattern for AI agents using Safari MCP:
302
304
 
303
305
  ---
304
306
 
305
- ## Tools (80)
307
+ ## Tools (96)
306
308
 
307
309
  <details>
308
310
  <summary><b>Click to expand the full tool list — organized by category</b></summary>
@@ -322,7 +324,7 @@ The recommended pattern for AI agents using Safari MCP:
322
324
  | `safari_get_source` | Get full HTML source |
323
325
  | `safari_navigate_and_read` | Navigate + read in one call |
324
326
 
325
- ### Click & Interaction (5)
327
+ ### Click & Interaction (6)
326
328
  | Tool | Description |
327
329
  |------|-------------|
328
330
  | `safari_click` | Click by CSS selector, visible text, or coordinates |
@@ -330,8 +332,9 @@ The recommended pattern for AI agents using Safari MCP:
330
332
  | `safari_right_click` | Right-click (context menu) |
331
333
  | `safari_hover` | Hover over element |
332
334
  | `safari_click_and_wait` | Click + wait for navigation |
335
+ | `safari_click_and_read` | Click then return the updated page — saves a round-trip (React Router + full loads) |
333
336
 
334
- ### Form Input (7)
337
+ ### Form Input (11)
335
338
  | Tool | Description |
336
339
  |------|-------------|
337
340
  | `safari_fill` | Fill input (React/Vue/Angular compatible) |
@@ -341,6 +344,10 @@ The recommended pattern for AI agents using Safari MCP:
341
344
  | `safari_fill_and_submit` | Fill form + submit in one call |
342
345
  | `safari_type_text` | Type real keystrokes (JS-based, no System Events) |
343
346
  | `safari_press_key` | Press key with modifiers |
347
+ | `safari_react_select_set` | Set a react-select v5 value via React fiber — bypasses the menu UI |
348
+ | `safari_react_select_list_options` | List a react-select v5 dropdown's options without opening it |
349
+ | `safari_replace_editor` | Replace all content in a code editor (Monaco, CodeMirror, Ace, ProseMirror) |
350
+ | `safari_verify_state` | Verify an editor's framework-level state matches expected — catch stale DOM before Submit |
344
351
 
345
352
  ### Screenshots & PDF (3)
346
353
  | Tool | Description |
@@ -356,13 +363,14 @@ The recommended pattern for AI agents using Safari MCP:
356
363
  | `safari_scroll_to` | Scroll to exact position |
357
364
  | `safari_scroll_to_element` | Smooth scroll to element |
358
365
 
359
- ### Tab Management (4)
366
+ ### Tab Management (5)
360
367
  | Tool | Description |
361
368
  |------|-------------|
362
369
  | `safari_list_tabs` | List all tabs (index, title, URL) |
363
370
  | `safari_new_tab` | Open new tab (background, no focus steal) |
364
371
  | `safari_close_tab` | Close tab |
365
372
  | `safari_switch_tab` | Switch to tab by index |
373
+ | `safari_wait_for_new_tab` | Wait for a new tab (e.g. OAuth popup) and auto-switch to it |
366
374
 
367
375
  ### Wait (2)
368
376
  | Tool | Description |
@@ -383,10 +391,11 @@ The recommended pattern for AI agents using Safari MCP:
383
391
  | `safari_get_computed_style` | Computed CSS styles |
384
392
  | `safari_detect_forms` | Auto-detect all forms with field selectors |
385
393
 
386
- ### Accessibility (1)
394
+ ### Accessibility (2)
387
395
  | Tool | Description |
388
396
  |------|-------------|
389
397
  | `safari_accessibility_snapshot` | Full a11y tree: roles, ARIA, focusable elements |
398
+ | `safari_snapshot` | Accessibility tree with ref IDs for every interactive element — preferred way to see page state |
390
399
 
391
400
  ### Drag & Drop (1)
392
401
  | Tool | Description |
@@ -411,7 +420,7 @@ The recommended pattern for AI agents using Safari MCP:
411
420
  | `safari_emulate` | Emulate device (iPhone, iPad, Pixel, Galaxy) |
412
421
  | `safari_reset_emulation` | Reset to desktop |
413
422
 
414
- ### Cookies & Storage (10)
423
+ ### Cookies & Storage (11)
415
424
  | Tool | Description |
416
425
  |------|-------------|
417
426
  | `safari_get_cookies` | Get all cookies |
@@ -464,7 +473,7 @@ The recommended pattern for AI agents using Safari MCP:
464
473
  | `safari_extract_images` | Images with dimensions and loading info |
465
474
  | `safari_extract_links` | Links with rel, external/nofollow detection |
466
475
 
467
- ### Advanced (5)
476
+ ### Advanced (7)
468
477
  | Tool | Description |
469
478
  |------|-------------|
470
479
  | `safari_override_geolocation` | Override browser geolocation |
@@ -472,12 +481,22 @@ The recommended pattern for AI agents using Safari MCP:
472
481
  | `safari_get_indexed_db` | Read IndexedDB records |
473
482
  | `safari_css_coverage` | Find unused CSS rules |
474
483
  | `safari_analyze_page` | Full page analysis in one call |
484
+ | `safari_doctor` | Diagnose the macOS permission + daemon chain (Apple Events, Accessibility, Screen Recording, codesign) with per-failure fixes |
485
+ | `safari_reload_extension` | Hot-reload the Safari MCP Bridge extension without a manual toggle |
475
486
 
476
487
  ### Automation (1)
477
488
  | Tool | Description |
478
489
  |------|-------------|
479
490
  | `safari_run_script` | Run multiple actions in a single call (batch) |
480
491
 
492
+ ### Native Input — CGEvent (4)
493
+ | Tool | Description |
494
+ |------|-------------|
495
+ | `safari_native_click` | OS-level mouse click (CGEvent, `isTrusted: true`) — bypasses WAF/bot detection when `safari_click` is blocked (405/403) |
496
+ | `safari_native_hover` | OS-level cursor hover — triggers real `:hover`/`mouseenter` for tooltips and obfuscated UIs |
497
+ | `safari_native_type` | Insert text via the real paste pipeline — ProseMirror/Slate/Draft.js process it natively so Submit sends real data |
498
+ | `safari_native_keyboard` | OS-level keypress + modifiers to Safari, no focus steal — reaches React trust-gated handlers (Discord/Slack send) |
499
+
481
500
  ### iOS & WebKit Validation (4)
482
501
  | Tool | Description |
483
502
  |------|-------------|
@@ -512,7 +531,7 @@ Safari MCP runs locally on your Mac with minimal attack surface:
512
531
  | Your logins | ✅ Yes | ✅ Yes | ❌ No |
513
532
  | macOS native | ✅ WebKit | ❌ Chromium | ❌ Chromium/WebKit |
514
533
  | Browser dependencies | None | Chrome + debug port | Playwright runtime |
515
- | Tools | 80 | ~30 | ~25 |
534
+ | Tools | 96 | ~30 | ~25 |
516
535
  | File upload | JS (no dialog) | CDP | Playwright API |
517
536
  | Image paste | JS (no clipboard) | CDP | Playwright API |
518
537
  | Focus steal | ❌ Background | ❌ Background | ❌ Headless |
@@ -522,13 +541,31 @@ Safari MCP runs locally on your Mac with minimal attack surface:
522
541
 
523
542
  > **Tip:** Use Safari MCP for daily browsing tasks (95% of work) and Chrome DevTools MCP only for Lighthouse/Performance audits.
524
543
 
544
+ ### vs Apple's Official Safari MCP (safaridriver)
545
+
546
+ In Safari Technology Preview 247 (July 2026), Apple shipped an **official** Safari MCP server built on `safaridriver`. That's great validation for the category — and it's built for a different job. Apple's server drives an **isolated WebDriver automation session** for debugging; safari-mcp drives the **real Safari you're already logged into**.
547
+
548
+ | | 🦁 safari-mcp *(this repo)* | Apple `safaridriver --mcp` |
549
+ |---|:---:|:---:|
550
+ | **Your real logins / cookies** | ✅ Your actual Safari | ⚠️ Isolated automation session — no access to AutoFill or browsing activity |
551
+ | **Runs on** | ✅ Stable Safari, every Mac | ❌ Safari Technology Preview 247 only |
552
+ | **Background (no focus steal)** | ✅ Yes | ❌ Dedicated window with a "controlled by automation" banner |
553
+ | **Tools** | **96** | ~17 |
554
+ | **Storage** (cookies, localStorage, IndexedDB) | ✅ 10 tools | ❌ |
555
+ | **Network mocking + throttling** | ✅ Yes | ❌ Read-only network inspection |
556
+ | **Device emulation** (iPhone, iPad) | ✅ Yes | ⚠️ Viewport + media type only |
557
+ | **Setup** | `npx safari-mcp` | Enable "remote automation and external agents" in STP |
558
+ | **Official Apple support** | ❌ Community (MIT) | ✅ Apple, WebDriver-standard |
559
+
560
+ > **When Apple's server is the right pick:** you specifically want a clean-room, WebDriver-standard session for compatibility debugging and you already run STP. **For everything else — daily automation on the browser you're already signed into, on stable Safari — safari-mcp is built for exactly that.**
561
+
525
562
  ### Why Safari MCP and Not the Other Safari MCP Projects?
526
563
 
527
564
  There are several "safari-mcp" projects floating around. Here's how they compare:
528
565
 
529
566
  | Feature | **🦁 safari-mcp** *(this repo)* | [lxman/safari-mcp-server](https://github.com/lxman/safari-mcp-server) | [Epistates/MCPSafari](https://github.com/Epistates/MCPSafari) | [HayoDev/safari-devtools-mcp](https://github.com/HayoDev/safari-devtools-mcp) |
530
567
  |---------|:------------------------------:|:----------------------:|:------------:|:----------------------:|
531
- | **Tools** | **80** | ~10 | 23 | ~15 |
568
+ | **Tools** | **96** | ~10 | 23 | ~15 |
532
569
  | **Install** | `npx safari-mcp` | Manual | Binary | `npx` |
533
570
  | **Engine** | **Dual** (Extension + AppleScript) | WebDriver | Extension only | DevTools Protocol |
534
571
  | **Keeps your real Safari logins** | ✅ Yes | ⚠️ Limited | ✅ Yes | ❌ Debug session |
@@ -795,7 +832,7 @@ If Safari MCP saves you from Chrome overhead, **a star helps others discover it:
795
832
 
796
833
  [![Star this repo](https://img.shields.io/github/stars/achiya-automation/safari-mcp?style=social)](https://github.com/achiya-automation/safari-mcp)
797
834
 
798
- [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.%2080%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/)
835
+ [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.%2096%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/)
799
836
 
800
837
  [![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)
801
838
 
package/glama.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://glama.ai/mcp/schemas/server.json",
3
3
  "name": "Safari MCP Server",
4
- "description": "Native Safari browser automation for AI agents. 80+ tools via AppleScript — zero overhead, keeps logins, runs silently in background.",
4
+ "description": "Native Safari browser automation for AI agents. 96 tools via AppleScript — zero overhead, keeps logins, runs silently in background.",
5
5
  "repository": "https://github.com/achiya-automation/safari-mcp",
6
6
  "homepage": "https://github.com/achiya-automation/safari-mcp",
7
7
  "author": "achiya-automation",
package/index.js CHANGED
@@ -7,15 +7,20 @@
7
7
 
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { startTransport } from "./transport.js";
10
11
  import { z } from "zod";
11
12
  import * as safari from "./safari.js";
12
- import { findOwnedMatch, pruneExpired } from "./ownership-match.js";
13
13
  import { textResult, jsonResult, imageResult, errorResult } from "./response.js";
14
+ import {
15
+ OWNERSHIP_DIR, BLANK_TAB_SENTINEL,
16
+ _openedTabs, _ownedTabURLs,
17
+ _isURLOwned, _markBlankTabOpened, _addOwnedURL, _removeOwnedURL, _trackTab, _untrackTab,
18
+ } from "./ownership-state.js";
14
19
  import { WebSocketServer } from "ws";
15
20
  import { createServer } from "node:http";
16
21
  import { randomUUID, randomBytes } from "node:crypto";
17
22
  import { execFile, execFileSync } from "node:child_process";
18
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync } from "node:fs";
23
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
19
24
  import { dirname, join } from "node:path";
20
25
  import { fileURLToPath } from "node:url";
21
26
  import { homedir } from "node:os";
@@ -49,130 +54,20 @@ const PROXY_TOKEN = _getProxyToken();
49
54
  // ========== SESSION ID (unique per MCP process — enables per-session tab tracking) ==========
50
55
  const SESSION_ID = randomUUID().slice(0, 8);
51
56
 
52
- // ========== PERSISTENT TAB OWNERSHIP ==========
53
- // The in-memory _ownedTabURLs set is wiped when the MCP process restarts
54
- // (Claude Code periodically recycles MCP servers). Without persistence, every
55
- // restart re-triggers "Tab safety: no tabs opened yet" errors forcing a
56
- // re-open of every tab. Persist the set to a JSON file with a TTL so tabs
57
- // remain "owned" across process restarts for up to OWNERSHIP_TTL_MS.
58
- const OWNERSHIP_DIR = join(homedir(), ".safari-mcp");
59
- const OWNERSHIP_FILE = join(OWNERSHIP_DIR, "owned-tabs.json");
60
- const OWNERSHIP_TTL_MS = 30 * 60 * 1000; // 30 minutes
61
-
62
- function _loadOwnershipFile() {
63
- try {
64
- if (!existsSync(OWNERSHIP_FILE)) return [];
65
- const raw = readFileSync(OWNERSHIP_FILE, "utf8");
66
- const data = JSON.parse(raw);
67
- if (!Array.isArray(data)) return [];
68
- const cutoff = Date.now() - OWNERSHIP_TTL_MS;
69
- return data.filter(e => e && typeof e.url === "string" && typeof e.ts === "number" && e.ts > cutoff);
70
- } catch { return []; }
71
- }
72
-
73
- function _saveOwnershipFile(urls) {
74
- try {
75
- if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
76
- const now = Date.now();
77
- const entries = Array.from(urls).map(url => ({ url, ts: _ownedTabTimestamps.get(url) ?? now }));
78
- // Atomic write (tmp + rename) — concurrent MCP instances share this file; a partial
79
- // write from one must never corrupt the JSON another instance reads.
80
- const tmp = OWNERSHIP_FILE + ".tmp." + process.pid;
81
- writeFileSync(tmp, JSON.stringify(entries), { mode: 0o600 });
82
- renameSync(tmp, OWNERSHIP_FILE);
83
- } catch { /* best-effort */ }
84
- }
57
+ // Persistent tab-ownership state + its helpers now live in ownership-state.js
58
+ // (OWNERSHIP_* consts, _ownedTabURLs/_ownedTabTimestamps, _loadOwnershipFile,
59
+ // _saveOwnershipFile, _isURLOwned, _addOwnedURL, _trackTab, … — imported at the top).
60
+ // OWNERSHIP_DIR is re-used below for the memory-monitor lock file.
85
61
 
86
62
  // ========== MEMORY GUARD: track & auto-close MCP-opened tabs ==========
87
63
  const MAX_TABS = parseInt(process.env.MCP_MAX_TABS || "6", 10);
88
64
  const MEMORY_CHECK_INTERVAL_MS = parseInt(process.env.MCP_MEMORY_CHECK_MS || "60000", 10);
89
65
  const WEBKIT_MEMORY_LIMIT_MB = parseInt(process.env.MCP_WEBKIT_LIMIT_MB || "3000", 10);
90
66
 
91
- // Track tabs opened by THIS session (index → {url, openedAt})
92
- const _openedTabs = new Map();
93
-
94
- // ========== TAB OWNERSHIP: prevent operating on user's tabs ==========
95
- // Tracks URLs of tabs opened by this MCP session.
96
- // Any tool that modifies a tab (navigate, click, fill, etc.) is blocked
97
- // unless the current tab was opened via safari_new_tab.
98
- // Hydrated from ~/.safari-mcp/owned-tabs.json so ownership survives MCP restarts.
99
- const _ownedTabURLs = new Set();
100
- // Preserve each entry's ORIGINAL timestamp so _saveOwnershipFile doesn't reset it to `now` on
101
- // every write — otherwise the 30-min TTL never expires anything while a session is active, and
102
- // stale ownership leaks onto the user's tabs across sessions.
103
- const _ownedTabTimestamps = new Map();
104
- for (const e of _loadOwnershipFile()) {
105
- _ownedTabURLs.add(e.url);
106
- _ownedTabTimestamps.set(e.url, e.ts);
107
- }
108
-
109
- // Touch-on-use + live TTL enforcement. The TTL exists so ownership doesn't outlive the
110
- // session's actual use of a tab: entries the session keeps asserting against stay fresh;
111
- // abandoned entries expire after OWNERSHIP_TTL_MS and can no longer match a user's tab.
112
- // (Previously the TTL was only applied when loading the file at startup, so a long-lived
113
- // session accumulated ownership forever.)
114
- function _touchOwned(ownedKey) {
115
- _ownedTabTimestamps.set(ownedKey, Date.now());
116
- return true;
117
- }
118
- function _pruneExpiredOwnership() {
119
- if (pruneExpired(_ownedTabURLs, _ownedTabTimestamps, OWNERSHIP_TTL_MS)) {
120
- _saveOwnershipFile(_ownedTabURLs);
121
- }
122
- }
123
-
124
- // Matching semantics (exact / normalized / same-origin path-prefix with a segment
125
- // boundary) live in ownership-match.js, where test/ownership-match.test.mjs locks
126
- // them — including that owning /org never owns /org-evil, and that the broad
127
- // "own the whole origin" rule stays dead (it defeated tab-safety entirely).
128
- function _isURLOwned(url) {
129
- if (!url) return false;
130
- _pruneExpiredOwnership();
131
- const match = findOwnedMatch(url, _ownedTabURLs);
132
- return match !== null ? _touchOwned(match) : false;
133
- }
134
-
135
- // Sentinel persisted when a blank tab (about:blank) is opened by this session.
136
- // A blank tab has no unique URL to own, but ownership must still survive an MCP
137
- // process restart (_openedTabs is in-memory only) — otherwise reopening blank
138
- // tabs falsely trips the "no tabs opened yet" guard. The sentinel is never a
139
- // real tab URL, so it cannot falsely match a user's page in _isURLOwned().
140
- const BLANK_TAB_SENTINEL = "__mcp-blank-tab__";
141
-
142
- function _markBlankTabOpened() {
143
- if (!_ownedTabURLs.has(BLANK_TAB_SENTINEL)) {
144
- _ownedTabTimestamps.set(BLANK_TAB_SENTINEL, Date.now());
145
- _ownedTabURLs.add(BLANK_TAB_SENTINEL);
146
- _saveOwnershipFile(_ownedTabURLs);
147
- }
148
- }
149
-
150
- function _addOwnedURL(url) {
151
- if (url && url !== 'about:blank' && url !== 'favorites://') {
152
- if (!_ownedTabTimestamps.has(url)) _ownedTabTimestamps.set(url, Date.now());
153
- _ownedTabURLs.add(url);
154
- _saveOwnershipFile(_ownedTabURLs);
155
- }
156
- }
157
-
158
- function _removeOwnedURL(url) {
159
- if (url) {
160
- _ownedTabURLs.delete(url);
161
- _ownedTabTimestamps.delete(url);
162
- _saveOwnershipFile(_ownedTabURLs);
163
- }
164
- }
165
-
166
- function _trackTab(tabIndex, url) {
167
- _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now() });
168
- _addOwnedURL(url);
169
- }
170
-
171
- function _untrackTab(tabIndex) {
172
- const info = _openedTabs.get(tabIndex);
173
- if (info?.url) _removeOwnedURL(info.url);
174
- _openedTabs.delete(tabIndex);
175
- }
67
+ // Tab-ownership state (_openedTabs / _ownedTabURLs / _ownedTabTimestamps), the on-disk
68
+ // persistence + TTL, and the helpers (_isURLOwned, _markBlankTabOpened, _addOwnedURL,
69
+ // _removeOwnedURL, _trackTab, _untrackTab, BLANK_TAB_SENTINEL) are imported from
70
+ // ownership-state.js at the top of this file.
176
71
 
177
72
  // Close all MCP-opened tabs on process exit
178
73
  async function _cleanupTabs() {
@@ -885,6 +780,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
885
780
 
886
781
  // Read version from package.json to avoid hardcoded mismatch
887
782
  const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
783
+ // Factory so each MCP session (in HTTP mode) gets its own McpServer — McpServer is single-connection.
784
+ // stdio mode calls this exactly once, identical to the historical inline server. Tool bodies are
785
+ // unchanged; they close over the module-global safari state, which stays shared across sessions
786
+ // (correct: one physical Safari window). See docs/http-transport-design.md.
787
+ function buildServer() {
888
788
  const server = new McpServer({
889
789
  name: "safari-mcp",
890
790
  version: _pkgVersion,
@@ -1654,6 +1554,17 @@ server.tool(
1654
1554
  }
1655
1555
  );
1656
1556
 
1557
+ server.tool(
1558
+ "safari_eval_file",
1559
+ "Execute JavaScript read from a FILE path (avoids passing huge scripts inline / manual copy). Same engine as safari_evaluate: extension-first (no focus steal), AppleScript fallback. Use to upload binary via a generated .js containing base64.",
1560
+ { path: z.string().describe("Absolute path to a .js file whose contents are the script to execute") },
1561
+ async (args) => {
1562
+ const script = readFileSync(args.path, "utf8");
1563
+ const result = await extensionOrFallback("evaluate", { script }, () => safari.evaluate({ script }));
1564
+ return { content: [{ type: "text", text: (typeof result === "string" ? result : JSON.stringify(result)) || "(no return value)" }] };
1565
+ }
1566
+ );
1567
+
1657
1568
  // ========== ELEMENT INFO ==========
1658
1569
 
1659
1570
  server.tool(
@@ -2460,6 +2371,9 @@ server.tool(
2460
2371
  }
2461
2372
  );
2462
2373
 
2374
+ return server;
2375
+ }
2376
+
2463
2377
  // ========== START SERVER ==========
2464
2378
 
2465
2379
  // One-time-per-day startup banner — visible CTA without spamming MCP logs.
@@ -2504,5 +2418,6 @@ _startMemoryMonitor();
2504
2418
  } catch { /* best-effort, never block startup */ }
2505
2419
  })();
2506
2420
 
2507
- const transport = new StdioServerTransport();
2508
- await server.connect(transport);
2421
+ // Transport is chosen at runtime: default stdio (unchanged), or a shared HTTP instance when
2422
+ // SAFARI_MCP_HTTP=1 (many Claude sessions → one process). buildServer is the per-session factory.
2423
+ await startTransport(buildServer, process.env);
@@ -0,0 +1,141 @@
1
+ // Tab-ownership state + tracking — the security-critical core that prevents the MCP
2
+ // session from operating on the USER's tabs. Extracted verbatim from index.js so the
3
+ // state and its helpers live in one reviewable, test-locked module (see
4
+ // tests/ownership-state.test.mjs). The pure matching/pruning semantics live next door in
5
+ // ownership-match.js; this module owns the *stateful* layer on top of them: the in-memory
6
+ // sets, the on-disk persistence (so ownership survives MCP restarts), and the TTL.
7
+ //
8
+ // Every symbol here was previously a module-level binding in index.js. All exported state
9
+ // is `const` (Map/Set) — mutated, never reassigned — so ESM live bindings keep index.js and
10
+ // any future src/tools/* module pointing at the exact same objects.
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { findOwnedMatch, pruneExpired } from "./ownership-match.js";
16
+
17
+ // MCP opens tabs, but a restart re-triggers "Tab safety: no tabs opened yet" errors forcing
18
+ // a re-open of every tab. Persist the set to a JSON file with a TTL so tabs remain "owned"
19
+ // across process restarts for up to OWNERSHIP_TTL_MS.
20
+ export const OWNERSHIP_DIR = join(homedir(), ".safari-mcp");
21
+ export const OWNERSHIP_FILE = join(OWNERSHIP_DIR, "owned-tabs.json");
22
+ export const OWNERSHIP_TTL_MS = 30 * 60 * 1000; // 30 minutes
23
+
24
+ export function _loadOwnershipFile() {
25
+ try {
26
+ if (!existsSync(OWNERSHIP_FILE)) return [];
27
+ const raw = readFileSync(OWNERSHIP_FILE, "utf8");
28
+ const data = JSON.parse(raw);
29
+ if (!Array.isArray(data)) return [];
30
+ const cutoff = Date.now() - OWNERSHIP_TTL_MS;
31
+ return data.filter(
32
+ (e) => e && typeof e.url === "string" && typeof e.ts === "number" && e.ts > cutoff
33
+ );
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ export function _saveOwnershipFile(urls) {
40
+ try {
41
+ if (!existsSync(OWNERSHIP_DIR)) mkdirSync(OWNERSHIP_DIR, { recursive: true });
42
+ const now = Date.now();
43
+ const entries = Array.from(urls).map((url) => ({
44
+ url,
45
+ ts: _ownedTabTimestamps.get(url) ?? now,
46
+ }));
47
+ // Atomic write (tmp + rename) — concurrent MCP instances share this file; a partial
48
+ // write from one must never corrupt the JSON another instance reads.
49
+ const tmp = OWNERSHIP_FILE + ".tmp." + process.pid;
50
+ writeFileSync(tmp, JSON.stringify(entries), { mode: 0o600 });
51
+ renameSync(tmp, OWNERSHIP_FILE);
52
+ } catch {
53
+ /* best-effort */
54
+ }
55
+ }
56
+
57
+ // Track tabs opened by THIS session (index → {url, openedAt})
58
+ export const _openedTabs = new Map();
59
+
60
+ // ========== TAB OWNERSHIP: prevent operating on user's tabs ==========
61
+ // Tracks URLs of tabs opened by this MCP session.
62
+ // Any tool that modifies a tab (navigate, click, fill, etc.) is blocked
63
+ // unless the current tab was opened via safari_new_tab.
64
+ // Hydrated from ~/.safari-mcp/owned-tabs.json so ownership survives MCP restarts.
65
+ export const _ownedTabURLs = new Set();
66
+ // Preserve each entry's ORIGINAL timestamp so _saveOwnershipFile doesn't reset it to `now` on
67
+ // every write — otherwise the 30-min TTL never expires anything while a session is active, and
68
+ // stale ownership leaks onto the user's tabs across sessions.
69
+ export const _ownedTabTimestamps = new Map();
70
+ for (const e of _loadOwnershipFile()) {
71
+ _ownedTabURLs.add(e.url);
72
+ _ownedTabTimestamps.set(e.url, e.ts);
73
+ }
74
+
75
+ // Touch-on-use + live TTL enforcement. The TTL exists so ownership doesn't outlive the
76
+ // session's actual use of a tab: entries the session keeps asserting against stay fresh;
77
+ // abandoned entries expire after OWNERSHIP_TTL_MS and can no longer match a user's tab.
78
+ // (Previously the TTL was only applied when loading the file at startup, so a long-lived
79
+ // session accumulated ownership forever.)
80
+ export function _touchOwned(ownedKey) {
81
+ _ownedTabTimestamps.set(ownedKey, Date.now());
82
+ return true;
83
+ }
84
+ export function _pruneExpiredOwnership() {
85
+ if (pruneExpired(_ownedTabURLs, _ownedTabTimestamps, OWNERSHIP_TTL_MS)) {
86
+ _saveOwnershipFile(_ownedTabURLs);
87
+ }
88
+ }
89
+
90
+ // Matching semantics (exact / normalized / same-origin path-prefix with a segment
91
+ // boundary) live in ownership-match.js, where test/ownership-match.test.mjs locks
92
+ // them — including that owning /org never owns /org-evil, and that the broad
93
+ // "own the whole origin" rule stays dead (it defeated tab-safety entirely).
94
+ export function _isURLOwned(url) {
95
+ if (!url) return false;
96
+ _pruneExpiredOwnership();
97
+ const match = findOwnedMatch(url, _ownedTabURLs);
98
+ return match !== null ? _touchOwned(match) : false;
99
+ }
100
+
101
+ // Sentinel persisted when a blank tab (about:blank) is opened by this session.
102
+ // A blank tab has no unique URL to own, but ownership must still survive an MCP
103
+ // process restart (_openedTabs is in-memory only) — otherwise reopening blank
104
+ // tabs falsely trips the "no tabs opened yet" guard. The sentinel is never a
105
+ // real tab URL, so it cannot falsely match a user's page in _isURLOwned().
106
+ export const BLANK_TAB_SENTINEL = "__mcp-blank-tab__";
107
+
108
+ export function _markBlankTabOpened() {
109
+ if (!_ownedTabURLs.has(BLANK_TAB_SENTINEL)) {
110
+ _ownedTabTimestamps.set(BLANK_TAB_SENTINEL, Date.now());
111
+ _ownedTabURLs.add(BLANK_TAB_SENTINEL);
112
+ _saveOwnershipFile(_ownedTabURLs);
113
+ }
114
+ }
115
+
116
+ export function _addOwnedURL(url) {
117
+ if (url && url !== "about:blank" && url !== "favorites://") {
118
+ if (!_ownedTabTimestamps.has(url)) _ownedTabTimestamps.set(url, Date.now());
119
+ _ownedTabURLs.add(url);
120
+ _saveOwnershipFile(_ownedTabURLs);
121
+ }
122
+ }
123
+
124
+ export function _removeOwnedURL(url) {
125
+ if (url) {
126
+ _ownedTabURLs.delete(url);
127
+ _ownedTabTimestamps.delete(url);
128
+ _saveOwnershipFile(_ownedTabURLs);
129
+ }
130
+ }
131
+
132
+ export function _trackTab(tabIndex, url) {
133
+ _openedTabs.set(tabIndex, { url: url || "", openedAt: Date.now() });
134
+ _addOwnedURL(url);
135
+ }
136
+
137
+ export function _untrackTab(tabIndex) {
138
+ const info = _openedTabs.get(tabIndex);
139
+ if (info?.url) _removeOwnedURL(info.url);
140
+ _openedTabs.delete(tabIndex);
141
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 96 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
@@ -66,6 +66,7 @@
66
66
  "response.js",
67
67
  "safari.js",
68
68
  "ownership-match.js",
69
+ "ownership-state.js",
69
70
  "mcp-helpers.js",
70
71
  "injected-validators.js",
71
72
  "injected-escape.js",
@@ -88,7 +89,7 @@
88
89
  },
89
90
  "devDependencies": {
90
91
  "@eslint/js": "^10.0.1",
91
- "@types/node": "^25.9.3",
92
+ "@types/node": "^26.0.0",
92
93
  "eslint": "^10.5.0",
93
94
  "globals": "^17.6.0",
94
95
  "jsdom": "^29.1.1",
package/safari.js CHANGED
@@ -4952,6 +4952,31 @@ export async function checkWebKitCompat() {
4952
4952
  // One-shot check of the whole macOS permission + daemon chain, so the
4953
4953
  // "it doesn't work even with permissions granted" failures (#14/#15/#29)
4954
4954
  // surface as one actionable checklist instead of scattered cryptic errors.
4955
+ // ========== macOS NATIVE-INPUT COMPAT ==========
4956
+ // CGEvent.postToPid (native clicks/keys/hover) can silently no-op on macOS 26+ (Tahoe) even
4957
+ // with Accessibility granted — the events are accepted by the API but never cross into Safari's
4958
+ // WebContent process (issue #29). doctor() prints the OS version so a bug report carries the
4959
+ // single most relevant fact, and flags the known-risky range so users reach for safari_evaluate
4960
+ // or extension-based clicks on trust-gated forms instead of chasing a phantom permission grant.
4961
+ // Pure (no I/O) so it's unit-tested directly — see test/macos-compat.test.mjs.
4962
+ export function macosCompatNote(productVersion) {
4963
+ const raw = String(productVersion ?? "").trim();
4964
+ const major = parseInt(raw.split(".")[0], 10);
4965
+ if (!Number.isFinite(major)) {
4966
+ return {
4967
+ version: "unknown",
4968
+ major: null,
4969
+ risky: false,
4970
+ line: "macOS version: unknown (sw_vers gave no parseable version)",
4971
+ };
4972
+ }
4973
+ const risky = major >= 26;
4974
+ const line = risky
4975
+ ? `macOS ${raw} ⚠ CGEvent native clicks/keys may silently no-op on macOS 26+ even with Accessibility granted (issue #29) — for trust-gated forms prefer safari_evaluate or extension-based safari_click.`
4976
+ : `macOS ${raw} — CGEvent native input supported.`;
4977
+ return { version: raw, major, risky, line };
4978
+ }
4979
+
4955
4980
  export async function doctor() {
4956
4981
  const checks = [];
4957
4982
  const add = (ok, label, detail, fix) => checks.push({ ok, label, detail, fix: ok ? null : fix });
@@ -5006,8 +5031,17 @@ export async function doctor() {
5006
5031
  add(idOk, "Helper codesign identity", idDetail,
5007
5032
  `Re-sign: codesign -s - -f --identifier com.achiya-automation.safari-mcp --entitlements safari-helper.entitlements "${helperPath}"`);
5008
5033
 
5034
+ // macOS version — the single most relevant fact for #29-class "native clicks silently fail"
5035
+ // reports. Best-effort: sw_vers is macOS-only and absent in sandboxes/CI, so never block doctor.
5036
+ let osLine = null;
5037
+ try {
5038
+ const { stdout } = await execFileAsync("sw_vers", ["-productVersion"], { timeout: 2000 });
5039
+ osLine = macosCompatNote(stdout).line;
5040
+ } catch { /* sw_vers unavailable — skip the line, the permission checks still stand */ }
5041
+
5009
5042
  const passed = checks.filter((c) => c.ok).length;
5010
5043
  const lines = [`Safari MCP doctor — ${passed}/${checks.length} checks passed`, ""];
5044
+ if (osLine) lines.push(osLine, "");
5011
5045
  for (const c of checks) {
5012
5046
  lines.push(`${c.ok ? "✅" : "❌"} ${c.label}: ${c.detail}`);
5013
5047
  if (!c.ok && c.fix) lines.push(` → ${c.fix}`);