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.
- package/extension/background.js +5 -0
- package/glama.json +2 -0
- package/index.js +15 -1
- package/package.json +1 -1
- package/safari.js +81 -27
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
+
_logProfile(`Startup: Profile "${SAFARI_PROFILE}" → targeting ${_targetWindowRef}`);
|
|
157
197
|
} else {
|
|
158
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|