safari-mcp 2.7.1 → 2.7.3

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,10 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <picture>
4
- <source media="(prefers-color-scheme: dark)" srcset="assets/banner.svg">
5
- <source media="(prefers-color-scheme: light)" srcset="assets/banner.svg">
6
- <img src="assets/banner.svg" alt="Safari MCP — Native browser automation for AI agents" width="100%">
7
- </picture>
3
+ <img src="social-preview.png" alt="Safari MCP Server — 80 native browser automation tools for AI agents on macOS" width="100%">
8
4
 
9
5
  <br/>
10
6
 
@@ -25,6 +21,7 @@
25
21
 
26
22
  <a href="vscode: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-Install_MCP_Server-0078d4?logo=visual-studio-code&logoColor=white" alt="Install in VS Code"></a>
27
23
  <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_MCP_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
24
+ <a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor"></a>
28
25
 
29
26
  **80 tools** · **Zero dependencies** · **~5ms per command** · **60% less CPU than Chrome**
30
27
 
@@ -533,6 +530,21 @@ The codebase is two files:
533
530
 
534
531
  ---
535
532
 
533
+ ## Sponsors
534
+
535
+ Safari MCP is free and open source. If it saves you time or CPU cycles, consider supporting its development:
536
+
537
+ <a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor via PayPal"></a>
538
+
539
+ Your support funds:
540
+ - 🧪 Testing across macOS versions and Safari releases
541
+ - 🛠️ New tools and features
542
+ - 📖 Documentation and examples
543
+
544
+ [Become the first sponsor!](https://paypal.me/achiyaC123)
545
+
546
+ ---
547
+
536
548
  ## Star History
537
549
 
538
550
  If Safari MCP saved you from Chrome overhead, consider giving it a ⭐
@@ -5,7 +5,7 @@
5
5
  const HTTP_URL = "http://127.0.0.1:9224";
6
6
  let isConnected = false;
7
7
  let pollAbort = null;
8
- let _targetProfile = null; // Profile name from server (e.g. "אוטומציות")
8
+ let _targetProfile = null; // Profile name from server (e.g. "Automations")
9
9
  let _profileWindowId = null; // Discovered windowId for the profile
10
10
  let _enabled = true; // Toggle from popup — when false, stops polling and rejects commands
11
11
  let _reconnectTimer = null; // Single reconnect timer — prevents exponential growth
@@ -1735,7 +1735,7 @@ browser.tabs.onRemoved.addListener((tabId) => {
1735
1735
 
1736
1736
  // Verify this extension instance is running in the expected profile.
1737
1737
  // Safari extensions see only their own profile's windows/tabs.
1738
- // If the server expects "אוטומציות" but this worker's windows don't match, we're in the wrong profile.
1738
+ // If the server expects a specific profile but this worker's windows don't match, we're in the wrong profile.
1739
1739
  async function _verifyProfileMatch(expectedProfile) {
1740
1740
  try {
1741
1741
  const allWindows = await browser.windows.getAll({ populate: true });
@@ -1,6 +1,6 @@
1
1
  // Content script — runs at document_start in MAIN world (before page scripts)
2
2
  // Purpose: Monkey-patch attachShadow to capture CLOSED shadow roots.
3
- // Without this, Reddit 2026 and other sites using closed shadow DOM
3
+ // Without this, Reddit and other sites using closed shadow DOM
4
4
  // are invisible to snapshot/click/fill.
5
5
  // Runs in MAIN world via manifest "world": "MAIN" — no script injection needed,
6
6
  // so CSP cannot block it.
@@ -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.4",
5
+ "version": "2.7.2",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html dir="ltr">
2
+ <html lang="en" dir="ltr">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <style>
package/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // 1. Safari Web Extension (fast, ~5-20ms, keeps logins) — when extension is connected
4
4
  // 2. AppleScript + Swift daemon (~5ms, keeps logins) — always available
5
5
  //
6
- // Extension transport: WebSocket (Chrome) OR HTTP polling (Safari — WebSocket blocked by Apple)
6
+ // Extension transport: HTTP polling (Safari — WebSocket blocked by Apple)
7
7
 
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -84,6 +84,13 @@ function _isURLOwned(url) {
84
84
  if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalize(owned))) return true;
85
85
  } catch {}
86
86
  }
87
+ // Broader same-origin check: if we own ANY URL on this origin, treat all same-origin URLs as owned.
88
+ // This handles redirects like /article/new → /article/edit/ID where path prefix doesn't match.
89
+ for (const owned of _ownedTabURLs) {
90
+ try {
91
+ if (new URL(owned).origin === urlOrigin) return true;
92
+ } catch {}
93
+ }
87
94
  } catch {}
88
95
  return false;
89
96
  }
@@ -386,6 +393,14 @@ try {
386
393
  const { execFile: execFileCb } = await import("node:child_process");
387
394
  const { promisify: pfy } = await import("node:util");
388
395
  const execFileAsync = pfy(execFileCb);
396
+ // Don't launch Safari if it's not running
397
+ try {
398
+ await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
399
+ } catch {
400
+ res.writeHead(200, { "Content-Type": "application/json" });
401
+ res.end(JSON.stringify({ match: false, error: "Safari is not running" }));
402
+ return;
403
+ }
389
404
  const script = `tell application "Safari"
390
405
  repeat with w in every window
391
406
  repeat with t in every tab of w
@@ -635,15 +650,19 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
635
650
  // must target an owned tab. This prevents navigating/clicking in user's tabs.
636
651
  if (!_noOwnershipCheck.has(extensionType)) {
637
652
  const currentUrl = safari.getActiveTabURL();
638
- if (_ownedTabURLs.size === 0) {
653
+ if (_ownedTabURLs.size === 0 && _openedTabs.size === 0) {
639
654
  // No tabs opened yet — block everything except read-only ops
640
655
  const msg = `⚠️ Tab safety: no tabs opened yet. Call safari_new_tab first before "${extensionType}".`;
641
656
  console.error(`[Safari MCP] ${msg}`);
642
657
  throw new Error(msg);
643
658
  } else if (currentUrl && !_isURLOwned(currentUrl)) {
644
- const msg = `⚠️ Tab safety: refusing "${extensionType}" — current tab (${currentUrl}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
645
- console.error(`[Safari MCP] ${msg}`);
646
- throw new Error(msg);
659
+ // about:blank tabs are owned if we have any tracked tabs (new_tab creates them at about:blank)
660
+ const isBlankOwned = (currentUrl === 'about:blank' || currentUrl === 'missing value') && _openedTabs.size > 0;
661
+ if (!isBlankOwned) {
662
+ const msg = `⚠️ Tab safety: refusing "${extensionType}" — current tab (${currentUrl}) was not opened by this MCP session. Use safari_new_tab or safari_switch_tab to target your own tab.`;
663
+ console.error(`[Safari MCP] ${msg}`);
664
+ throw new Error(msg);
665
+ }
647
666
  }
648
667
  }
649
668
  if (_extensionConnected && !_preferAppleScript) {
@@ -1203,9 +1222,13 @@ server.tool(
1203
1222
  const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
1204
1223
  const target = parsed.find(t => t.index === index);
1205
1224
  if (target && target.url && !_isURLOwned(target.url)) {
1206
- const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${target.url}) — not opened by this MCP session. Use safari_new_tab to open your own tab.`;
1207
- console.error(`[Safari MCP] ${msg}`);
1208
- return { content: [{ type: "text", text: msg }], isError: true };
1225
+ // about:blank / missing value tabs are owned if tracked in _openedTabs
1226
+ const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && _openedTabs.has(index);
1227
+ if (!isBlankOwned) {
1228
+ const msg = `⚠️ Tab safety: refusing switch_tab to index ${index} (${target.url}) — not opened by this MCP session. Use safari_new_tab to open your own tab.`;
1229
+ console.error(`[Safari MCP] ${msg}`);
1230
+ return { content: [{ type: "text", text: msg }], isError: true };
1231
+ }
1209
1232
  }
1210
1233
  } catch {}
1211
1234
  }
package/mcp-helpers.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // MCP Click/Interaction Helpers — injected into every page
2
2
  // This file is loaded by safari.js at startup, escaped, and injected via AppleScript `do JavaScript`.
3
3
  // It is also used by the extension's execInTab for the AppleScript fallback path.
4
- // IMPORTANT: Keep this file compatible with all browsers — no ES6+ modules, use var where possible.
4
+ // IMPORTANT: Keep compatible with all browsers — no ES6+ modules, use var where possible.
5
5
 
6
6
  if (window.__mcpVersion !== 5) {
7
7
  window.__mcpVersion = 5;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.7.1",
3
+ "version": "2.7.3",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
@@ -39,8 +39,8 @@
39
39
  "license": "MIT",
40
40
  "homepage": "https://achiya-automation.com",
41
41
  "funding": {
42
- "type": "github",
43
- "url": "https://github.com/sponsors/achiya-automation"
42
+ "type": "individual",
43
+ "url": "https://paypal.me/achiyaC123"
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
package/safari.js CHANGED
@@ -89,6 +89,21 @@ function cleanupHelper() {
89
89
  process.on("exit", cleanupHelper);
90
90
  process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
91
91
 
92
+ // ========== SAFARI RUNNING CHECK ==========
93
+ // Prevent AppleScript from auto-launching Safari when it's closed
94
+ async function isSafariRunning() {
95
+ try {
96
+ const { stdout } = await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
97
+ return stdout.trim().length > 0;
98
+ } catch {
99
+ return false; // pgrep exits 1 when no match
100
+ }
101
+ }
102
+
103
+ function safariNotRunningError() {
104
+ return new Error("Safari is not running. Open Safari manually before using Safari MCP tools.");
105
+ }
106
+
92
107
  // ========== CLIPBOARD LOCK ==========
93
108
  // Prevents concurrent clipboard operations from clobbering the user's clipboard.
94
109
  // While locked, any new clipboard operation waits until the current one completes.
@@ -326,6 +341,7 @@ async function resolveActiveTab() {
326
341
 
327
342
  // Run AppleScript — uses execFile (safe, isolated, for complex scripts)
328
343
  async function osascript(script, { timeout = 10000 } = {}) {
344
+ if (!(await isSafariRunning())) throw safariNotRunningError();
329
345
  try {
330
346
  const { stdout } = await execFileAsync("osascript", ["-e", script], {
331
347
  timeout,
@@ -349,6 +365,7 @@ async function osascript(script, { timeout = 10000 } = {}) {
349
365
 
350
366
  // osascriptFast: uses persistent Swift daemon (~5ms) — 18x faster than subprocess (~90ms)
351
367
  async function osascriptFast(script, { timeout = 10000 } = {}) {
368
+ if (!(await isSafariRunning())) throw safariNotRunningError();
352
369
  if (!_helperProc) startHelper();
353
370
  if (_helperProc) {
354
371
  try {
@@ -659,7 +676,7 @@ export async function navigate(url) {
659
676
  const state = await runJS('document.readyState', { timeout: 5000 });
660
677
  if (state === 'complete' || state === 'interactive') {
661
678
  result = await runJS(
662
- `JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('אין אפשרות')})`,
679
+ `JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('\u05D0\u05D9\u05DF \u05D0\u05E4\u05E9\u05E8\u05D5\u05EA')})`,
663
680
  { timeout: 5000 }
664
681
  );
665
682
  if (state === 'complete') break;
@@ -2069,7 +2086,7 @@ export async function uploadFile({ selector, filePath }) {
2069
2086
  click button "Cancel" of sheet 1 of w
2070
2087
  on error
2071
2088
  try
2072
- click button "ביטול" of sheet 1 of w
2089
+ click button "\u05D1\u05D9\u05D8\u05D5\u05DC" of sheet 1 of w -- "Cancel" in Hebrew locale
2073
2090
  on error
2074
2091
  key code 53
2075
2092
  end try
@@ -1,57 +0,0 @@
1
- // v6 — asks background script for real-time status, falls back to storage
2
- async function checkStatus() {
3
- const enabled = await getVal("mcpEnabled", true);
4
- document.getElementById("enableToggle").checked = enabled;
5
-
6
- if (!enabled) {
7
- setStatus("paused", "Paused", "Toggle to resume");
8
- return;
9
- }
10
-
11
- // Ask background script for real-time status
12
- try {
13
- const resp = await browser.runtime.sendMessage({ action: "getStatus" });
14
- if (resp) {
15
- if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
16
- if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
17
- }
18
- } catch {}
19
-
20
- // Fallback: read from storage
21
- const status = await getVal("mcpStatus", "checking");
22
- switch (status) {
23
- case "connected":
24
- setStatus("connected", "Connected", "Port 9224");
25
- break;
26
- case "paused":
27
- setStatus("paused", "Paused", "Toggle to resume");
28
- break;
29
- case "checking":
30
- setStatus("checking", "Connecting...", "Trying port 9224...");
31
- break;
32
- default:
33
- setStatus("disconnected", "Not connected", "Start the MCP server to connect");
34
- }
35
- }
36
-
37
- function setStatus(dotClass, text, info) {
38
- document.getElementById("statusDot").className = "dot " + dotClass;
39
- document.getElementById("statusText").textContent = text;
40
- document.getElementById("info").textContent = info;
41
- }
42
-
43
- document.getElementById("enableToggle").addEventListener("change", async (e) => {
44
- const enabled = e.target.checked;
45
- await browser.storage.local.set({ mcpEnabled: enabled });
46
- try { browser.runtime.sendMessage({ action: "setEnabled", enabled }); } catch {}
47
- setTimeout(checkStatus, 500);
48
- });
49
-
50
- async function getVal(key, defaultVal) {
51
- try {
52
- const data = await browser.storage.local.get(key);
53
- return data[key] !== undefined ? data[key] : defaultVal;
54
- } catch { return defaultVal; }
55
- }
56
-
57
- checkStatus();