safari-mcp 2.1.6 → 2.1.7

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.
@@ -92,6 +92,11 @@ async function connect() {
92
92
  updateBadge("OFF");
93
93
  return; // Do NOT poll — let the correct profile's extension handle commands
94
94
  }
95
+ // Notify server that we passed profile verification
96
+ await fetch(`${HTTP_URL}/extension-verified`, {
97
+ method: "POST",
98
+ signal: AbortSignal.timeout(3000),
99
+ }).catch(() => {});
95
100
  await _discoverProfileWindow();
96
101
  }
97
102
  isConnected = true;
package/glama.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
+ "$schema": "https://glama.ai/mcp/schemas/server.json",
2
3
  "name": "Safari MCP Server",
3
4
  "description": "Native Safari browser automation for AI agents. 80+ tools via AppleScript — zero overhead, keeps logins, runs silently in background.",
4
5
  "repository": "https://github.com/achiya-automation/safari-mcp",
5
6
  "homepage": "https://github.com/achiya-automation/safari-mcp",
6
7
  "author": "achiya-automation",
8
+ "maintainers": ["achiya-automation"],
7
9
  "license": "MIT",
8
10
  "categories": ["browser-automation", "web-scraping", "developer-tools"],
9
11
  "tags": ["safari", "macos", "browser", "automation", "applescript", "mcp"],
package/index.js CHANGED
@@ -235,7 +235,9 @@ try {
235
235
 
236
236
  // POST /connect — extension announces it's alive
237
237
  if (req.method === "POST" && req.url === "/connect") {
238
- if (!_extensionConnected) {
238
+ // When SAFARI_PROFILE is set, don't mark as connected until profile is verified.
239
+ // A personal-profile extension connecting first would incorrectly set the flag.
240
+ if (!process.env.SAFARI_PROFILE && !_extensionConnected) {
239
241
  _extensionConnected = true;
240
242
  console.error("[Safari MCP] Extension connected via HTTP polling");
241
243
  }
@@ -245,6 +247,18 @@ try {
245
247
  return;
246
248
  }
247
249
 
250
+ // POST /extension-verified — extension confirmed it's in the correct profile
251
+ if (req.method === "POST" && req.url === "/extension-verified") {
252
+ if (!_extensionConnected) {
253
+ _extensionConnected = true;
254
+ console.error("[Safari MCP] Extension connected and profile-verified via HTTP polling");
255
+ }
256
+ _extensionLastPollTime = Date.now();
257
+ res.writeHead(200, { "Content-Type": "application/json" });
258
+ res.end(JSON.stringify({ status: "verified" }));
259
+ return;
260
+ }
261
+
248
262
  // POST /verify-profile — extension asks server to check which profile has a nonce tab
249
263
  if (req.method === "POST" && req.url === "/verify-profile") {
250
264
  let body = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.1.6",
3
+ "version": "2.1.7",
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",
package/safari.js CHANGED
@@ -7,7 +7,7 @@ import { execFile, spawn } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, dirname } from "node:path";
10
- import { readFile, writeFile, unlink } from "node:fs/promises";
10
+ import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
11
11
  import { fileURLToPath } from "node:url";
12
12
  // Extension bridge is handled by index.js (WebSocket server on port 9223)
13
13
 
@@ -99,6 +99,16 @@ let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
99
99
  let _lastResolveTime = 0; // Cache: skip resolve if verified recently
100
100
  const RESOLVE_CACHE_MS = 1500; // Re-verify tab every 1.5 seconds (was 5s — too stale)
101
101
 
102
+ // ========== DIAGNOSTIC LOG ==========
103
+ // File-based log for profile/focus issues — survives MCP restart, visible to user
104
+ const _LOG_FILE = '/tmp/safari-mcp-profile.log';
105
+ function _logProfile(msg) {
106
+ const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
107
+ const line = `[${ts}] ${msg}\n`;
108
+ console.error(`[Safari MCP] ${msg}`);
109
+ appendFile(_LOG_FILE, line).catch(() => {});
110
+ }
111
+
102
112
  // ========== PROFILE TARGETING ==========
103
113
  // Set SAFARI_PROFILE env var to target a specific Safari profile window.
104
114
  // Safari shows profile windows as: "ProfileName — Tab Title"
@@ -106,7 +116,7 @@ const SAFARI_PROFILE = process.env.SAFARI_PROFILE || null;
106
116
  let _targetWindowRef = null; // null = not yet discovered. Updated by refreshTargetWindow()
107
117
  let _targetWindowId = null; // Numeric window ID (for CGEvent window targeting)
108
118
  let _targetWindowCacheTime = 0;
109
- const TARGET_WINDOW_CACHE_MS = 5000;
119
+ const TARGET_WINDOW_CACHE_MS = 2000; // Reduced from 5s — faster detection of window changes
110
120
  let _profileWindowMissing = false; // True when profile window is not found
111
121
 
112
122
  // Get the target window ref, falling back to 'front window' ONLY when no profile is configured
@@ -125,14 +135,16 @@ async function refreshTargetWindow(force = false) {
125
135
  const now = Date.now();
126
136
  if (!force && _targetWindowRef && (now - _targetWindowCacheTime) < TARGET_WINDOW_CACHE_MS) return;
127
137
  const safeProfile = SAFARI_PROFILE.replace(/"/g, '\\"');
138
+ // Find profile window by name AND verify the window ID still matches
128
139
  const result = await osascriptFast(
129
- `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return id of w\n end repeat\n return 0\nend tell`
130
- ).catch(() => '0');
131
- const id = Number(result);
140
+ `tell application "Safari"\n repeat with w in every window\n if name of w starts with "${safeProfile} \u2014" then return (id of w as text) & "|" & name of w\n end repeat\n return "0|"\nend tell`
141
+ ).catch(() => '0|');
142
+ const [idStr, windowName] = String(result).split('|');
143
+ const id = Number(idStr);
132
144
  if (id > 0) {
133
145
  const newRef = `window id ${id}`;
134
146
  if (_targetWindowRef && _targetWindowRef !== newRef) {
135
- console.error(`[Safari MCP] Profile window changed: ${_targetWindowRef} → ${newRef}`);
147
+ _logProfile(`Profile window changed: ${_targetWindowRef} → ${newRef} ("${windowName}")`);
136
148
  }
137
149
  _targetWindowRef = newRef;
138
150
  _targetWindowId = id;
@@ -144,18 +156,46 @@ async function refreshTargetWindow(force = false) {
144
156
  _targetWindowId = null;
145
157
  _targetWindowCacheTime = 0;
146
158
  _profileWindowMissing = true;
147
- console.error(`[Safari MCP] WARNING: Profile "${SAFARI_PROFILE}" window not found — refusing to use front window`);
159
+ _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window not found — refusing to use front window`);
148
160
  }
149
161
  }
150
162
 
163
+ // Background verification: periodically check that cached window ID still belongs to profile
164
+ if (SAFARI_PROFILE) {
165
+ setInterval(async () => {
166
+ if (!_targetWindowRef || !_targetWindowId) return;
167
+ try {
168
+ const name = await osascriptFast(
169
+ `tell application "Safari" to return name of ${_targetWindowRef}`
170
+ ).catch(() => '');
171
+ if (name && !name.startsWith(`${SAFARI_PROFILE} \u2014`)) {
172
+ // Window ID no longer belongs to profile — invalidate cache immediately
173
+ _logProfile(`SAFETY: Window ${_targetWindowRef} no longer belongs to profile "${SAFARI_PROFILE}" (name: "${name}") — invalidating`);
174
+ _targetWindowRef = null;
175
+ _targetWindowId = null;
176
+ _targetWindowCacheTime = 0;
177
+ _profileWindowMissing = true;
178
+ // Try to rediscover immediately
179
+ await refreshTargetWindow(true);
180
+ }
181
+ } catch {
182
+ // Window might have been closed — invalidate and rediscover
183
+ _targetWindowRef = null;
184
+ _targetWindowId = null;
185
+ _targetWindowCacheTime = 0;
186
+ await refreshTargetWindow(true);
187
+ }
188
+ }, 3000); // Check every 3 seconds
189
+ }
190
+
151
191
  // Initialize profile window at startup (ES module top-level await)
152
192
  if (SAFARI_PROFILE) {
153
193
  await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
154
194
  await refreshTargetWindow(true);
155
195
  if (_targetWindowRef) {
156
- console.error(`[Safari MCP] Profile "${SAFARI_PROFILE}" → targeting ${_targetWindowRef}`);
196
+ _logProfile(`Startup: Profile "${SAFARI_PROFILE}" → targeting ${_targetWindowRef}`);
157
197
  } else {
158
- console.error(`[Safari MCP] WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
198
+ _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
159
199
  }
160
200
  }
161
201
 
@@ -811,11 +851,21 @@ const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
811
851
  clientX:r.left+r.width/2,clientY:r.top+r.height/2,pageX:r.left+r.width/2+window.scrollX,pageY:r.top+r.height/2+window.scrollY,
812
852
  preventDefault:function(){},stopPropagation:function(){},nativeEvent:new MouseEvent(type||'click'),persist:function(){},bubbles:true,cancelable:true};
813
853
  }
854
+ // For React radio/checkbox inputs, onChange is the primary handler (not onClick)
855
+ var isToggle=startNode.tagName==='INPUT'&&(startNode.type==='radio'||startNode.type==='checkbox');
856
+ function tryOnChange(propsObj,targetNode){
857
+ if(!isToggle||!propsObj.onChange)return false;
858
+ var newChecked=startNode.type==='radio'?true:!startNode.checked;
859
+ propsObj.onChange({target:{value:startNode.value,checked:newChecked,type:startNode.type,name:startNode.name,id:startNode.id,tagName:'INPUT'},
860
+ currentTarget:targetNode,preventDefault:function(){},stopPropagation:function(){},nativeEvent:new Event('change'),persist:function(){},bubbles:true,cancelable:true,type:'change'});
861
+ return true;
862
+ }
814
863
  var node=startNode;
815
864
  for(var depth=0;depth<15&&node;depth++){
816
865
  var pk=Object.keys(node).find(function(k){return k.startsWith('__reactProps$')});
817
866
  if(pk&&node[pk]){
818
867
  var props=node[pk];
868
+ if(tryOnChange(props,node))return true;
819
869
  if(props.onClick){props.onClick(makeSynth(node,'click'));return true}
820
870
  if(props.onMouseDown){props.onMouseDown(makeSynth(node,'mousedown'));return true}
821
871
  if(props.onPointerDown){props.onPointerDown(makeSynth(node,'pointerdown'));return true}
@@ -825,6 +875,7 @@ const INJECT_MCP_HELPERS = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
825
875
  var f=node[fk];
826
876
  while(f){
827
877
  if(f.memoizedProps){
878
+ if(tryOnChange(f.memoizedProps,node))return true;
828
879
  if(f.memoizedProps.onClick){f.memoizedProps.onClick(makeSynth(node,'click'));return true}
829
880
  if(f.memoizedProps.onMouseDown){f.memoizedProps.onMouseDown(makeSynth(node,'mousedown'));return true}
830
881
  if(f.memoizedProps.onPointerDown){f.memoizedProps.onPointerDown(makeSynth(node,'pointerdown'));return true}
@@ -1527,30 +1578,35 @@ export async function replaceEditorContent({ text }) {
1527
1578
  export async function screenshot({ fullPage = false } = {}) {
1528
1579
  await refreshTargetWindow();
1529
1580
  const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
1530
- let savedTabIdx = null;
1531
1581
  try {
1532
- // If working on a background tab, temporarily switch to it for screenshot
1582
+ // Check if target tab is a background tab — if so, use JS screenshot to avoid tab jumping
1583
+ let isBackgroundTab = false;
1533
1584
  if (_activeTabIndex) {
1534
1585
  try {
1535
1586
  const currentIdx = await osascriptFast(
1536
1587
  `tell application "Safari" to return index of current tab of ${getTargetWindowRef()}`
1537
1588
  );
1538
- if (Number(currentIdx) !== _activeTabIndex) {
1539
- savedTabIdx = Number(currentIdx);
1540
- await osascriptFast(
1541
- `tell application "Safari" to set current tab of ${getTargetWindowRef()} to tab ${_activeTabIndex} of ${getTargetWindowRef()}`
1542
- );
1543
- await new Promise(r => setTimeout(r, 200)); // Let Safari render the tab
1544
- }
1589
+ isBackgroundTab = Number(currentIdx) !== _activeTabIndex;
1545
1590
  } catch (_) {}
1546
1591
  }
1547
- const skipScreencapture = false; // Always try screencapture now
1592
+ // When on a background tab, go straight to JS-based screenshot (no tab switch, no focus steal)
1593
+ const skipScreencapture = isBackgroundTab;
1548
1594
 
1549
1595
  // Try screencapture — use osascript's do shell script to bypass VS Code permission issue
1550
1596
  const windowId = !skipScreencapture ? await osascript(
1551
1597
  `tell application "Safari" to return id of ${getTargetWindowRef()}`
1552
1598
  ).catch(() => null) : null;
1553
1599
 
1600
+ // Save frontmost app BEFORE screencapture (it may steal focus on macOS Tahoe)
1601
+ let previousApp = null;
1602
+ if (windowId) {
1603
+ try {
1604
+ previousApp = (await osascriptFast(
1605
+ `tell application "System Events" to return name of first application process whose frontmost is true`
1606
+ )).trim();
1607
+ } catch (_) {}
1608
+ }
1609
+
1554
1610
  if (windowId) {
1555
1611
  try {
1556
1612
  if (fullPage) {
@@ -1585,6 +1641,12 @@ export async function screenshot({ fullPage = false } = {}) {
1585
1641
  );
1586
1642
  }
1587
1643
  }
1644
+ // Restore focus if screencapture stole it (common on macOS Tahoe)
1645
+ if (previousApp && previousApp !== "Safari") {
1646
+ await osascriptFast(
1647
+ `tell application "${previousApp}" to activate`
1648
+ ).catch(() => {});
1649
+ }
1588
1650
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
1589
1651
  // Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
1590
1652
  const jpgFile = tmpFile.replace('.png', '.jpg');
@@ -1638,14 +1700,6 @@ export async function screenshot({ fullPage = false } = {}) {
1638
1700
  throw new Error("screencapture failed — Screen Recording permission may have been lost. Grant permission in System Settings → Privacy & Security → Screen Recording, then restart Safari.");
1639
1701
  } finally {
1640
1702
  await unlink(tmpFile).catch(() => {});
1641
- // Restore user's tab if we switched
1642
- if (savedTabIdx) {
1643
- try {
1644
- await osascriptFast(
1645
- `tell application "Safari" to set current tab of ${getTargetWindowRef()} to tab ${savedTabIdx} of ${getTargetWindowRef()}`
1646
- );
1647
- } catch (_) {}
1648
- }
1649
1703
  }
1650
1704
  }
1651
1705