safari-mcp 2.7.1 → 2.7.4

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
 
@@ -34,6 +31,8 @@
34
31
 
35
32
  </div>
36
33
 
34
+ > **If Safari MCP saves you from running Chrome, consider giving it a star!** It helps others discover this project.
35
+
37
36
  ---
38
37
 
39
38
  > **TL;DR:** Use your real Safari with all your logins, cookies, and sessions. No headless browsers, no Chrome, no Puppeteer. Just pure AppleScript + JavaScript running natively on macOS — 60% less CPU/heat on Apple Silicon.
@@ -533,9 +532,41 @@ The codebase is two files:
533
532
 
534
533
  ---
535
534
 
535
+ ## Sponsors
536
+
537
+ Safari MCP is free and open source. If it saves you time or CPU cycles, consider supporting its development:
538
+
539
+ <a href="https://paypal.me/achiyaC123"><img src="https://img.shields.io/badge/Sponsor-PayPal-blue?logo=paypal" alt="Sponsor via PayPal"></a>
540
+
541
+ Your support funds:
542
+ - 🧪 Testing across macOS versions and Safari releases
543
+ - 🛠️ New tools and features
544
+ - 📖 Documentation and examples
545
+
546
+ [Become the first sponsor!](https://paypal.me/achiyaC123)
547
+
548
+ ---
549
+
550
+ ## Community
551
+
552
+ Safari MCP is used by developers building AI agents on macOS. [Share your use case in Discussions](https://github.com/achiya-automation/safari-mcp/discussions)
553
+
554
+ ---
555
+
556
+ ## Spread the Word
557
+
558
+ If you find Safari MCP useful, help others discover it:
559
+
560
+ - [Star this repo](https://github.com/achiya-automation/safari-mcp)
561
+ - [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)
562
+ - [Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fgithub.com%2Fachiya-automation%2Fsafari-mcp)
563
+ - Write about it on dev.to or your blog
564
+
565
+ ---
566
+
536
567
  ## Star History
537
568
 
538
- If Safari MCP saved you from Chrome overhead, consider giving it a ⭐
569
+ If Safari MCP saved you from Chrome overhead, consider giving it a star!
539
570
 
540
571
  [![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)
541
572
 
@@ -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",
@@ -20,6 +20,12 @@
20
20
  "host_permissions": ["<all_urls>"],
21
21
  "content_scripts": [{
22
22
  "matches": ["<all_urls>"],
23
+ "exclude_matches": [
24
+ "*://*.linkedin.com/*",
25
+ "*://*.x.com/*",
26
+ "*://*.twitter.com/*",
27
+ "*://*.producthunt.com/*"
28
+ ],
23
29
  "js": ["content.js"],
24
30
  "run_at": "document_start",
25
31
  "world": "MAIN"
@@ -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.d.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Safari MCP — TypeScript type declarations
3
+ * Covers all ~83 registered tools and the MCP server export.
4
+ */
5
+
6
+ // ========== CORE TYPES ==========
7
+
8
+ export type MCPContent =
9
+ | { type: "text"; text: string }
10
+ | { type: "image"; data: string; mimeType: string };
11
+
12
+ export interface MCPToolResult {
13
+ content: MCPContent[];
14
+ isError?: boolean;
15
+ }
16
+
17
+ export interface MCPToolInputSchema {
18
+ type: "object";
19
+ properties: Record<string, { type: string; description?: string; enum?: string[] }>;
20
+ required?: string[];
21
+ }
22
+
23
+ export interface MCPTool<TInput extends Record<string, unknown> = Record<string, unknown>> {
24
+ name: string;
25
+ description: string;
26
+ inputSchema: MCPToolInputSchema;
27
+ handler: (input: TInput) => Promise<MCPToolResult>;
28
+ }
29
+
30
+
31
+ // ========== TOOL INPUT TYPES ==========
32
+
33
+ // Navigation
34
+ export interface SafariNavigateInput { url: string }
35
+ export interface SafariGoBackInput {}
36
+ export interface SafariGoForwardInput {}
37
+ export interface SafariReloadInput { hard?: boolean }
38
+ export interface SafariNavigateAndReadInput { url: string; maxLength?: number; timeout?: number }
39
+
40
+ // Page Info
41
+ export interface SafariReadPageInput { selector?: string; maxLength?: number }
42
+ export interface SafariGetSourceInput { maxLength?: number }
43
+ export interface SafariSnapshotInput { selector?: string }
44
+
45
+ // Click
46
+ export interface SafariClickInput { ref?: string; selector?: string; text?: string; x?: number; y?: number }
47
+ export interface SafariClickAndReadInput { text?: string; selector?: string; x?: number; y?: number; wait?: number; maxLength?: number }
48
+ export interface SafariDoubleClickInput { selector?: string; x?: number; y?: number }
49
+ export interface SafariRightClickInput { selector?: string; x?: number; y?: number }
50
+ export interface SafariNativeClickInput { ref?: string; selector?: string; text?: string; x?: number; y?: number; doubleClick?: boolean }
51
+
52
+ // Form Input
53
+ export interface SafariFillInput { ref?: string; selector?: string; value: string }
54
+ export interface SafariClearFieldInput { selector: string }
55
+ export interface SafariSelectOptionInput { selector: string; value: string }
56
+ export interface SafariFillFormField { selector: string; value: string }
57
+ export interface SafariFillFormInput { fields: SafariFillFormField[] }
58
+
59
+ // Keyboard
60
+ export interface SafariPressKeyInput { key: string; modifiers?: string[] }
61
+ export interface SafariTypeTextInput { text: string; ref?: string; selector?: string }
62
+
63
+ // Code Editor
64
+ export interface SafariReplaceEditorInput { text: string }
65
+
66
+ // Screenshot
67
+ export interface SafariScreenshotInput { fullPage?: boolean }
68
+ export interface SafariScreenshotElementInput { selector: string }
69
+
70
+ // Scroll
71
+ export interface SafariScrollInput { direction?: "up" | "down"; amount?: number }
72
+ export interface SafariScrollToInput { x?: number; y?: number }
73
+ export interface SafariScrollToElementInput {
74
+ selector?: string;
75
+ text?: string;
76
+ block?: "start" | "center" | "end" | "nearest";
77
+ timeout?: number;
78
+ }
79
+
80
+ // Tab Management
81
+ export interface SafariListTabsInput {}
82
+ export interface SafariNewTabInput { url?: string }
83
+ export interface SafariCloseTabInput {}
84
+ export interface SafariSwitchTabInput { index: number }
85
+ export interface SafariWaitForNewTabInput { timeout?: number; urlContains?: string }
86
+
87
+ // Wait
88
+ export interface SafariWaitForInput { selector?: string; text?: string; timeout?: number }
89
+ export interface SafariWaitInput { ms: number }
90
+
91
+ // Evaluate
92
+ export interface SafariEvaluateInput { script: string }
93
+
94
+ // Element Info
95
+ export interface SafariGetElementInput { selector: string }
96
+ export interface SafariQueryAllInput { selector: string; limit?: number }
97
+
98
+ // Hover
99
+ export interface SafariHoverInput { ref?: string; selector?: string; x?: number; y?: number }
100
+
101
+ // Dialog
102
+ export interface SafariHandleDialogInput { action?: "accept" | "dismiss"; text?: string }
103
+
104
+ // Window
105
+ export interface SafariResizeInput { width: number; height: number }
106
+
107
+ // Drag
108
+ export interface SafariDragInput {
109
+ sourceSelector?: string;
110
+ targetSelector?: string;
111
+ sourceX?: number;
112
+ sourceY?: number;
113
+ targetX?: number;
114
+ targetY?: number;
115
+ }
116
+
117
+ // File / Image
118
+ export interface SafariUploadFileInput { selector: string; filePath: string }
119
+ export interface SafariPasteImageInput { filePath: string }
120
+
121
+ // Emulation
122
+ export interface SafariEmulateInput { device?: string; width?: number; height?: number; userAgent?: string; scale?: number }
123
+ export interface SafariResetEmulationInput {}
124
+
125
+ // Cookies & Storage
126
+ export interface SafariGetCookiesInput {}
127
+ export interface SafariLocalStorageInput { key?: string }
128
+ export interface SafariSetCookieInput {
129
+ name: string;
130
+ value: string;
131
+ domain?: string;
132
+ path?: string;
133
+ expires?: string;
134
+ secure?: boolean;
135
+ sameSite?: "Strict" | "Lax" | "None";
136
+ }
137
+ export interface SafariDeleteCookiesInput { name?: string; all?: boolean }
138
+ export interface SafariSessionStorageInput { key?: string }
139
+ export interface SafariSetSessionStorageInput { key: string; value: string }
140
+ export interface SafariSetLocalStorageInput { key: string; value: string }
141
+ export interface SafariDeleteLocalStorageInput { key?: string }
142
+ export interface SafariDeleteSessionStorageInput { key?: string }
143
+ export interface SafariExportStorageInput {}
144
+ export interface SafariImportStorageInput { state: string }
145
+
146
+ // Clipboard
147
+ export interface SafariClipboardReadInput {}
148
+ export interface SafariClipboardWriteInput { text: string }
149
+
150
+ // Network
151
+ export interface SafariNetworkInput { limit?: number }
152
+ export interface SafariMockRouteResponse { status?: number; body?: string; contentType?: string }
153
+ export interface SafariMockRouteInput { urlPattern: string; response: SafariMockRouteResponse }
154
+ export interface SafariClearMocksInput {}
155
+ export interface SafariStartNetworkCaptureInput {}
156
+ export interface SafariNetworkDetailsInput { limit?: number; filter?: string }
157
+ export interface SafariClearNetworkInput {}
158
+ export interface SafariThrottleNetworkInput { profile?: string; latency?: number; downloadKbps?: number }
159
+
160
+ // Console
161
+ export interface SafariStartConsoleInput {}
162
+ export interface SafariGetConsoleInput {}
163
+ export interface SafariClearConsoleInput {}
164
+ export interface SafariConsoleFilterInput { level: "log" | "warn" | "error" | "info" }
165
+
166
+ // PDF
167
+ export interface SafariSavePDFInput { path: string }
168
+
169
+ // Accessibility
170
+ export interface SafariAccessibilitySnapshotInput { selector?: string; maxDepth?: number }
171
+
172
+ // Performance
173
+ export interface SafariPerformanceMetricsInput {}
174
+ export interface SafariCSSCoverageInput {}
175
+
176
+ // Data Extraction
177
+ export interface SafariExtractTablesInput { selector?: string; limit?: number }
178
+ export interface SafariExtractMetaInput {}
179
+ export interface SafariExtractImagesInput { limit?: number }
180
+ export interface SafariExtractLinksInput { limit?: number; filter?: string }
181
+
182
+ // Geolocation
183
+ export interface SafariOverrideGeolocationInput { latitude: number; longitude: number; accuracy?: number }
184
+
185
+ // Computed Styles
186
+ export interface SafariGetComputedStyleInput { selector: string; properties?: string[] }
187
+
188
+ // IndexedDB
189
+ export interface SafariListIndexedDBsInput {}
190
+ export interface SafariGetIndexedDBInput { dbName: string; storeName: string; limit?: number }
191
+
192
+ // Form Detection
193
+ export interface SafariDetectFormsInput {}
194
+
195
+ // Batch / Combo Tools
196
+ export interface SafariRunScriptStep { action: string; args?: Record<string, unknown> }
197
+ export interface SafariRunScriptInput { steps: SafariRunScriptStep[] }
198
+ export interface SafariClickAndWaitInput { selector?: string; text?: string; waitFor?: string; timeout?: number }
199
+ export interface SafariFillAndSubmitInput { fields: SafariFillFormField[]; submitSelector?: string }
200
+ export interface SafariAnalyzePageInput {}
201
+
202
+ // ========== TAB INFO ==========
203
+
204
+ export interface SafariTab {
205
+ index: number;
206
+ title: string;
207
+ url: string;
208
+ }
209
+
210
+ // ========== MODULE DECLARATION ==========
211
+ export interface McpServer {
212
+ connect(transport: unknown): Promise<void>;
213
+ tool(name: string, description: string, schema: unknown, handler: (input: unknown) => Promise<MCPToolResult>): unknown;
214
+ close(): Promise<void>;
215
+ }
216
+
217
+ declare const server: McpServer;
218
+ export default server;
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
@@ -511,11 +526,11 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
511
526
  }
512
527
 
513
528
  let _extensionLastPollTime = 0;
514
- // Detect stale HTTP connection (no poll in 10s = disconnected)
529
+ // Detect stale HTTP connection (no poll in 30s = disconnected)
515
530
  // Only applies to primary instance (extension host) — not proxy mode
516
531
  setInterval(() => {
517
532
  if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
518
- if (Date.now() - _extensionLastPollTime > 10000) {
533
+ if (Date.now() - _extensionLastPollTime > 30000) {
519
534
  _extensionConnected = false;
520
535
  _drainOnDisconnect("HTTP poll timeout");
521
536
  console.error("[Safari MCP] Extension disconnected (HTTP poll timeout)");
@@ -635,44 +650,68 @@ 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
- if (_extensionConnected && !_preferAppleScript) {
650
- try {
651
- const t0 = Date.now();
652
- const tabUrl = safari.getActiveTabURL();
653
- const payload = { ...extensionPayload, sessionId: SESSION_ID, ...(tabUrl ? { tabUrl } : {}) };
654
- const timeout = _commandTimeouts[extensionType] || 30000;
655
- const result = await sendToExtension(extensionType, payload, timeout);
656
- const isCspError = typeof result === 'string' && (result.includes('unsafe-eval') || result.includes('trusted-types') || result.includes('Trusted Type') || result.includes('Content Security Policy'));
657
- const isPermissionDenied = typeof result === 'string' && result.includes('__SCREENSHOT_PERMISSION_DENIED__');
658
- const isFailed = result === null || (typeof result === 'string' && result.startsWith('Element not found'));
659
- if (isPermissionDenied) {
660
- console.error(`[Safari MCP] ${extensionType} permission denied (${Date.now() - t0}ms) — falling back to AppleScript`);
661
- } else if (isCspError) {
662
- console.error(`[Safari MCP] ${extensionType} CSP blocked: ${result?.substring(0, 100)} (${Date.now() - t0}ms) — falling back to AppleScript`);
663
- } else if (isFailed && _nullMeansFailure.has(extensionType)) {
664
- console.error(`[Safari MCP] ${extensionType} extension failed: ${result} (${Date.now() - t0}ms) — falling back to AppleScript`);
665
- } else {
666
- console.error(`[Safari MCP] ${extensionType} via extension (${Date.now() - t0}ms)`);
667
- return result;
668
+
669
+ // ========== FOCUS PRESERVATION ==========
670
+ // Safari AppleScript/extension can steal focus (bring Safari window to front).
671
+ // Save the frontmost app before the operation and restore it after if Safari stole focus.
672
+ // Set focusGuard flag so inner osascript/runJSLarge calls skip their own focus logic.
673
+ const savedApp = await safari.saveFrontmostApp();
674
+ safari.setFocusGuard(true);
675
+
676
+ let result;
677
+ let usedExtension = false;
678
+ try {
679
+ if (_extensionConnected && !_preferAppleScript) {
680
+ try {
681
+ const t0 = Date.now();
682
+ const tabUrl = safari.getActiveTabURL();
683
+ const payload = { ...extensionPayload, sessionId: SESSION_ID, ...(tabUrl ? { tabUrl } : {}) };
684
+ const timeout = _commandTimeouts[extensionType] || 30000;
685
+ result = await sendToExtension(extensionType, payload, timeout);
686
+ const isCspError = typeof result === 'string' && (result.includes('unsafe-eval') || result.includes('trusted-types') || result.includes('Trusted Type') || result.includes('Content Security Policy'));
687
+ const isPermissionDenied = typeof result === 'string' && result.includes('__SCREENSHOT_PERMISSION_DENIED__');
688
+ const isFailed = result === null || (typeof result === 'string' && result.startsWith('Element not found'));
689
+ if (isPermissionDenied) {
690
+ console.error(`[Safari MCP] ${extensionType} permission denied (${Date.now() - t0}ms) — falling back to AppleScript`);
691
+ } else if (isCspError) {
692
+ console.error(`[Safari MCP] ${extensionType} CSP blocked: ${result?.substring(0, 100)} (${Date.now() - t0}ms) — falling back to AppleScript`);
693
+ } else if (isFailed && _nullMeansFailure.has(extensionType)) {
694
+ console.error(`[Safari MCP] ${extensionType} extension failed: ${result} (${Date.now() - t0}ms) — falling back to AppleScript`);
695
+ } else {
696
+ console.error(`[Safari MCP] ${extensionType} via extension (${Date.now() - t0}ms)`);
697
+ usedExtension = true;
698
+ }
699
+ } catch (err) {
700
+ console.error(`[Safari MCP] ${extensionType} extension failed: ${err.message} — falling back to AppleScript`);
668
701
  }
669
- } catch (err) {
670
- console.error(`[Safari MCP] ${extensionType} extension failed: ${err.message} — falling back to AppleScript`);
671
702
  }
703
+ if (!usedExtension) {
704
+ const t0 = Date.now();
705
+ result = await fallbackFn();
706
+ console.error(`[Safari MCP] ${extensionType} via AppleScript (${Date.now() - t0}ms)`);
707
+ }
708
+ } finally {
709
+ safari.setFocusGuard(false);
672
710
  }
673
- const t0 = Date.now();
674
- const result = await fallbackFn();
675
- console.error(`[Safari MCP] ${extensionType} via AppleScript (${Date.now() - t0}ms)`);
711
+
712
+ // Restore focus if Safari stole it
713
+ await safari.restoreFocusIfStolen(savedApp);
714
+
676
715
  return result;
677
716
  }
678
717
 
@@ -1075,7 +1114,10 @@ server.tool(
1075
1114
  "Take a screenshot of a specific element (by CSS selector). Returns base64 PNG image.",
1076
1115
  { selector: z.string().describe("CSS selector of the element to capture") },
1077
1116
  async ({ selector }) => {
1078
- const base64 = await safari.screenshotElement({ selector });
1117
+ const base64 = await extensionOrFallback(
1118
+ "screenshot_element", { selector },
1119
+ () => safari.screenshotElement({ selector })
1120
+ );
1079
1121
  return {
1080
1122
  content: [{ type: "image", data: base64, mimeType: "image/jpeg" }],
1081
1123
  };
@@ -1203,9 +1245,13 @@ server.tool(
1203
1245
  const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
1204
1246
  const target = parsed.find(t => t.index === index);
1205
1247
  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 };
1248
+ // about:blank / missing value tabs are owned if tracked in _openedTabs
1249
+ const isBlankOwned = (target.url === 'about:blank' || target.url === 'missing value') && _openedTabs.has(index);
1250
+ if (!isBlankOwned) {
1251
+ 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.`;
1252
+ console.error(`[Safari MCP] ${msg}`);
1253
+ return { content: [{ type: "text", text: msg }], isError: true };
1254
+ }
1209
1255
  }
1210
1256
  } catch {}
1211
1257
  }
@@ -1559,7 +1605,7 @@ server.tool(
1559
1605
 
1560
1606
  server.tool(
1561
1607
  "safari_save_pdf",
1562
- "Save the current page as a PDF file. Uses Safari's native Export as PDF. WARNING: This STEALS FOCUS — Safari comes to foreground briefly for the Export menu.",
1608
+ "Save the current page as a PDF file. Uses screencapture + PDF rendering (no Safari UI interaction needed).",
1563
1609
  { path: z.string().describe("Absolute file path to save the PDF (e.g. /Users/am/Downloads/page.pdf)") },
1564
1610
  async (args) => {
1565
1611
  const result = await safari.savePDF(args);
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,9 +1,10 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.7.1",
3
+ "version": "2.7.4",
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",
7
+ "types": "index.d.ts",
7
8
  "main": "index.js",
8
9
  "bin": {
9
10
  "safari-mcp": "index.js"
@@ -39,8 +40,8 @@
39
40
  "license": "MIT",
40
41
  "homepage": "https://achiya-automation.com",
41
42
  "funding": {
42
- "type": "github",
43
- "url": "https://github.com/sponsors/achiya-automation"
43
+ "type": "individual",
44
+ "url": "https://paypal.me/achiyaC123"
44
45
  },
45
46
  "repository": {
46
47
  "type": "git",
@@ -64,7 +65,8 @@
64
65
  "extension/",
65
66
  "glama.json",
66
67
  "LICENSE",
67
- "README.md"
68
+ "README.md",
69
+ "index.d.ts"
68
70
  ],
69
71
  "dependencies": {
70
72
  "@modelcontextprotocol/sdk": "^1.12.1",
package/safari-helper CHANGED
Binary file
@@ -278,12 +278,52 @@ while let line = readLine(strippingNewline: true) {
278
278
  continue
279
279
  }
280
280
 
281
+ // Handle getFrontApp — returns frontmost application bundle ID (native, ~0.1ms)
282
+ if json["getFrontApp"] != nil {
283
+ let frontApp = NSWorkspace.shared.frontmostApplication
284
+ respond(["result": frontApp?.localizedName ?? "", "bundleId": frontApp?.bundleIdentifier ?? ""])
285
+ continue
286
+ }
287
+
288
+ // Handle activateApp — activate a specific app by bundle ID (native, ~1ms)
289
+ // Used to restore focus to previous app instead of hiding Safari (less jarring)
290
+ if let bundleId = json["activateApp"] as? String {
291
+ if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId).first {
292
+ app.activate()
293
+ respond(["result": "activated"])
294
+ } else {
295
+ respond(["error": "app not found: \(bundleId)"])
296
+ }
297
+ continue
298
+ }
299
+
300
+ // Handle hideSafari — hide Safari to prevent focus stealing (native, ~1ms)
301
+ if json["hideSafari"] != nil {
302
+ if let safariApp = NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.Safari").first {
303
+ safariApp.hide()
304
+ respond(["result": "hidden"])
305
+ } else {
306
+ respond(["error": "Safari not running"])
307
+ }
308
+ continue
309
+ }
310
+
281
311
  // Handle AppleScript command
282
312
  guard let script = json["script"] as? String else {
283
- respond(["error": "invalid input — expected 'script', 'click', or 'keyboard'"])
313
+ respond(["error": "invalid input — expected 'script', 'click', 'keyboard', 'getFrontApp', or 'hideSafari'"])
284
314
  continue
285
315
  }
286
316
 
317
+ // ========== FOCUS PRESERVATION ==========
318
+ // Save the frontmost app BEFORE executing AppleScript.
319
+ // Safari AppleScript can steal focus — we re-activate the previous app immediately after.
320
+ // Re-activating is better than hiding Safari because:
321
+ // 1. No jarring hide animation on Safari windows
322
+ // 2. User's Safari windows stay visible (just not in front)
323
+ // 3. Almost instantaneous — previous app just comes back to foreground
324
+ let savedBundleId = NSWorkspace.shared.frontmostApplication?.bundleIdentifier
325
+ let wasSafari = savedBundleId == "com.apple.Safari"
326
+
287
327
  guard let nsScript = NSAppleScript(source: script) else {
288
328
  respond(["error": "failed to compile AppleScript"])
289
329
  continue
@@ -292,6 +332,15 @@ while let line = readLine(strippingNewline: true) {
292
332
  var errorDict: NSDictionary?
293
333
  let result = nsScript.executeAndReturnError(&errorDict)
294
334
 
335
+ // Restore focus to previous app if Safari stole it (only if caller wasn't already in Safari)
336
+ if !wasSafari,
337
+ NSWorkspace.shared.frontmostApplication?.bundleIdentifier == "com.apple.Safari" {
338
+ if let savedId = savedBundleId,
339
+ let previousApp = NSRunningApplication.runningApplications(withBundleIdentifier: savedId).first {
340
+ previousApp.activate()
341
+ }
342
+ }
343
+
295
344
  if let error = errorDict {
296
345
  let msg = (error["NSAppleScriptErrorMessage"] as? String) ?? "AppleScript error"
297
346
  respond(["error": msg])
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.
@@ -162,6 +177,7 @@ let _targetWindowId = null; // Numeric window ID (for CGEvent window targeting)
162
177
  let _targetWindowCacheTime = 0;
163
178
  const TARGET_WINDOW_CACHE_MS = 1000; // Short cache — fast detection of window changes
164
179
  let _profileWindowMissing = false; // True when profile window is not found
180
+ let _focusGuardActive = false; // True when an outer caller already handles focus save/restore
165
181
 
166
182
  // Get the target window ref, falling back to 'front window' ONLY when no profile is configured
167
183
  function getTargetWindowRef() {
@@ -258,6 +274,56 @@ function getFallbackTarget() {
258
274
  // Quick JS execution — exposed for smart-wait checks in index.js
259
275
  export async function runJSQuick(js) { return runJS(js); }
260
276
 
277
+ // ========== FOCUS PRESERVATION ==========
278
+ // Safari AppleScript can steal focus (bring Safari window to front).
279
+ // We hide Safari instead of activating the previous app — prevents ANY Safari window from flashing.
280
+ export async function saveFrontmostApp() {
281
+ const app = await _helperGetFrontApp();
282
+ return app?.bundleId || null;
283
+ }
284
+ export async function restoreFocusIfStolen(savedBundleId) {
285
+ if (!savedBundleId || savedBundleId === "com.apple.Safari") return;
286
+ const current = await _helperGetFrontApp();
287
+ if (current?.bundleId === "com.apple.Safari") {
288
+ await _helperActivateApp(savedBundleId);
289
+ }
290
+ }
291
+
292
+ function _helperHideSafari(timeout = 2000) {
293
+ return new Promise((resolve) => {
294
+ if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
295
+ let resolved = false;
296
+ const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
297
+ function cb() {
298
+ if (resolved) return;
299
+ resolved = true;
300
+ clearTimeout(timer);
301
+ resolve();
302
+ }
303
+ _helperQueue.push(cb);
304
+ try { _helperProc.stdin.write('{"hideSafari":true}\n'); }
305
+ catch { clearTimeout(timer); resolve(); }
306
+ });
307
+ }
308
+
309
+ function _helperActivateApp(bundleId, timeout = 2000) {
310
+ return new Promise((resolve) => {
311
+ if (!_helperProc || !_helperProc.stdin?.writable) { resolve(); return; }
312
+ let resolved = false;
313
+ const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(); } }, timeout);
314
+ function cb() {
315
+ if (resolved) return;
316
+ resolved = true;
317
+ clearTimeout(timer);
318
+ resolve();
319
+ }
320
+ _helperQueue.push(cb);
321
+ try { _helperProc.stdin.write(JSON.stringify({ activateApp: bundleId }) + '\n'); }
322
+ catch { clearTimeout(timer); resolve(); }
323
+ });
324
+ }
325
+
326
+ export function setFocusGuard(active) { _focusGuardActive = active; }
261
327
  export function getActiveTabIndex() { return _activeTabIndex; }
262
328
  export function setActiveTabIndex(idx) { _activeTabIndex = idx; }
263
329
  export function getActiveTabURL() { return _activeTabURL; }
@@ -326,6 +392,11 @@ async function resolveActiveTab() {
326
392
 
327
393
  // Run AppleScript — uses execFile (safe, isolated, for complex scripts)
328
394
  async function osascript(script, { timeout = 10000 } = {}) {
395
+ if (!(await isSafariRunning())) throw safariNotRunningError();
396
+ // Save frontmost app via daemon BEFORE subprocess (~0.1ms)
397
+ // Skip if an outer caller (extensionOrFallback) already handles focus
398
+ const shouldGuardFocus = !_focusGuardActive;
399
+ const frontApp = shouldGuardFocus ? await _helperGetFrontApp() : null;
329
400
  try {
330
401
  const { stdout } = await execFileAsync("osascript", ["-e", script], {
331
402
  timeout,
@@ -344,11 +415,20 @@ async function osascript(script, { timeout = 10000 } = {}) {
344
415
  }
345
416
  }
346
417
  throw new Error(`AppleScript error: ${err.stderr || err.message}`);
418
+ } finally {
419
+ // Re-activate previous app if Safari stole focus (~1ms)
420
+ if (shouldGuardFocus && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
421
+ const current = await _helperGetFrontApp();
422
+ if (current?.bundleId === 'com.apple.Safari') {
423
+ _helperActivateApp(frontApp.bundleId).catch(() => {});
424
+ }
425
+ }
347
426
  }
348
427
  }
349
428
 
350
429
  // osascriptFast: uses persistent Swift daemon (~5ms) — 18x faster than subprocess (~90ms)
351
430
  async function osascriptFast(script, { timeout = 10000 } = {}) {
431
+ if (!(await isSafariRunning())) throw safariNotRunningError();
352
432
  if (!_helperProc) startHelper();
353
433
  if (_helperProc) {
354
434
  try {
@@ -503,6 +583,26 @@ function _helperNativeKeyboard(keyCode, flags = [], windowId = 0, timeout = 5000
503
583
  });
504
584
  }
505
585
 
586
+ // ========== NATIVE FOCUS OPERATIONS VIA DAEMON ==========
587
+ // Uses NSRunningApplication — ~0.1ms for get, ~1ms for activate (vs ~90ms AppleScript)
588
+
589
+ function _helperGetFrontApp(timeout = 2000) {
590
+ return new Promise((resolve) => {
591
+ if (!_helperProc || !_helperProc.stdin?.writable) { resolve(null); return; }
592
+ let resolved = false;
593
+ const timer = setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, timeout);
594
+ function cb(line) {
595
+ if (resolved) return;
596
+ resolved = true;
597
+ clearTimeout(timer);
598
+ try { resolve(JSON.parse(line)); } catch { resolve(null); }
599
+ }
600
+ _helperQueue.push(cb);
601
+ try { _helperProc.stdin.write('{"getFrontApp":true}\n'); }
602
+ catch { clearTimeout(timer); resolve(null); }
603
+ });
604
+ }
605
+
506
606
  // Get Safari window bounds, toolbar height, and window ID for coordinate calculation
507
607
  async function _getSafariWindowGeometry() {
508
608
  await refreshTargetWindow();
@@ -615,6 +715,10 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
615
715
  const appleScript = `tell application "Safari" to do JavaScript "${escaped}" in ${target}`;
616
716
  const tmpFile = join(tmpdir(), `safari-mcp-${Date.now()}.scpt`);
617
717
  await writeFile(tmpFile, appleScript, "utf8");
718
+ // Save frontmost app via daemon before subprocess execution
719
+ // Skip if outer caller already handles focus
720
+ const shouldGuard = !_focusGuardActive;
721
+ const frontApp = shouldGuard ? await _helperGetFrontApp() : null;
618
722
  try {
619
723
  const { stdout } = await execFileAsync("osascript", [tmpFile], {
620
724
  timeout,
@@ -623,6 +727,13 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
623
727
  return stdout.trim();
624
728
  } finally {
625
729
  unlink(tmpFile).catch(() => {});
730
+ // Re-activate previous app if Safari stole focus (~1ms)
731
+ if (shouldGuard && frontApp?.bundleId && frontApp.bundleId !== 'com.apple.Safari') {
732
+ const current = await _helperGetFrontApp();
733
+ if (current?.bundleId === 'com.apple.Safari') {
734
+ _helperActivateApp(frontApp.bundleId).catch(() => {});
735
+ }
736
+ }
626
737
  }
627
738
  }
628
739
 
@@ -659,7 +770,7 @@ export async function navigate(url) {
659
770
  const state = await runJS('document.readyState', { timeout: 5000 });
660
771
  if (state === 'complete' || state === 'interactive') {
661
772
  result = await runJS(
662
- `JSON.stringify({title:document.title,url:location.href,blocked:document.title.includes('cannot open')||document.title.includes('אין אפשרות')})`,
773
+ `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
774
  { timeout: 5000 }
664
775
  );
665
776
  if (state === 'complete') break;
@@ -1487,15 +1598,11 @@ export async function screenshot({ fullPage = false } = {}) {
1487
1598
  ).catch(() => null) : null;
1488
1599
 
1489
1600
  // On macOS Tahoe, screencapture -l may briefly steal focus.
1490
- // Save frontmost app so we can detect if focus was stolen and restore it.
1491
- let previousApp = null;
1601
+ // Save frontmost app via daemon so we can hide Safari if it stole focus.
1602
+ let previousBundleId = null;
1492
1603
  if (windowId) {
1493
- try {
1494
- // Use NSWorkspace via AppleScript (lighter than System Events — no AX needed)
1495
- previousApp = (await osascriptFast(
1496
- `tell application "System Events" to return name of first application process whose frontmost is true`
1497
- )).trim();
1498
- } catch (_) {}
1604
+ const fa = await _helperGetFrontApp();
1605
+ previousBundleId = fa?.bundleId || null;
1499
1606
  }
1500
1607
 
1501
1608
  if (windowId) {
@@ -1536,11 +1643,12 @@ export async function screenshot({ fullPage = false } = {}) {
1536
1643
  );
1537
1644
  }
1538
1645
  }
1539
- // Restore focus if screencapture stole it (common on macOS Tahoe)
1540
- if (previousApp && previousApp !== "Safari") {
1541
- await osascriptFast(
1542
- `tell application "${previousApp}" to activate`
1543
- ).catch(() => {});
1646
+ // Re-activate previous app if screencapture stole focus (common on macOS Tahoe)
1647
+ if (previousBundleId && previousBundleId !== "com.apple.Safari") {
1648
+ const cur = await _helperGetFrontApp();
1649
+ if (cur?.bundleId === "com.apple.Safari") {
1650
+ await _helperActivateApp(previousBundleId).catch(() => {});
1651
+ }
1544
1652
  }
1545
1653
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
1546
1654
  // Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
@@ -2069,7 +2177,7 @@ export async function uploadFile({ selector, filePath }) {
2069
2177
  click button "Cancel" of sheet 1 of w
2070
2178
  on error
2071
2179
  try
2072
- click button "ביטול" of sheet 1 of w
2180
+ click button "\u05D1\u05D9\u05D8\u05D5\u05DC" of sheet 1 of w -- "Cancel" in Hebrew locale
2073
2181
  on error
2074
2182
  key code 53
2075
2183
  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();