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 +52 -15
- package/glama.json +1 -1
- package/index.js +37 -122
- package/ownership-state.js +141 -0
- package/package.json +3 -2
- package/safari.js +34 -0
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 —
|
|
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
|
-
**
|
|
27
|
+
**96 tools** · **No Chrome/Puppeteer/Playwright needed** · **~5ms per command** · **60% less CPU than Chrome**
|
|
28
28
|
|
|
29
|
-
[Quick Start](#quick-start) · [All
|
|
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
|

|
|
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.
|
|
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
|
-
- **
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 |
|
|
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** | **
|
|
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
|
[](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.%
|
|
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
|
[](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.
|
|
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
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
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
|
-
|
|
2508
|
-
|
|
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.
|
|
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": "^
|
|
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}`);
|