safari-mcp 2.15.8 → 2.15.9
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 +32 -0
- package/package.json +1 -1
- package/safari.js +66 -74
package/README.md
CHANGED
|
@@ -304,6 +304,38 @@ The recommended pattern for AI agents using Safari MCP:
|
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|
|
307
|
+
## Running several agents at once
|
|
308
|
+
|
|
309
|
+
Multiple agents or subagents driving one Safari at the same time will fight over the active tab — unless you run them against a **shared HTTP daemon** instead of one process per client:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
SAFARI_MCP_HTTP=1 SAFARI_MCP_HTTP_PORT=9225 npx safari-mcp
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Then point every client at it:
|
|
316
|
+
|
|
317
|
+
```json
|
|
318
|
+
{ "mcpServers": { "safari-mcp": { "type": "http", "url": "http://127.0.0.1:9225/mcp" } } }
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
One daemon, many sessions — and each session gets its **own tab state**. The server keys `activeTabIndex`, the ownership flag and a unique tab marker off the MCP session id, so session A physically cannot read or steer session B's tab.
|
|
322
|
+
|
|
323
|
+
Two properties make this safe rather than merely tidy:
|
|
324
|
+
|
|
325
|
+
- **Tab identity is a marker, not an index.** Each session stamps a unique id into the page it opens, so ownership survives navigation *and* survives the user reordering or closing other tabs. An index alone would silently drift onto the wrong tab.
|
|
326
|
+
- **It fails closed.** If a session's marked tab can't be re-found, every tool refuses instead of falling back to whatever tab is in front — because that tab is usually yours:
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
Tab tracking lost — refusing to fall back to "current tab of window"
|
|
330
|
+
(would target the user's active tab). Call safari_new_tab to reopen.
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
This also drops process count sharply: ~17 node processes for 17 concurrent sessions becomes 1.
|
|
334
|
+
|
|
335
|
+
`SAFARI_PROFILE` stays optional — leave it unset and sessions bind to your ordinary Safari windows, cookies and logins intact. Details in [docs/http-transport-design.md](docs/http-transport-design.md).
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
307
339
|
## Tools (97)
|
|
308
340
|
|
|
309
341
|
<details>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.9",
|
|
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",
|
package/safari.js
CHANGED
|
@@ -508,7 +508,7 @@ export async function restoreFocusIfStolen(savedBundleId) {
|
|
|
508
508
|
// callers fail toward "user is idle" (preserving the pre-existing hide behavior).
|
|
509
509
|
async function _userIdleSeconds() {
|
|
510
510
|
try {
|
|
511
|
-
const { stdout } = await execFileAsync("ioreg", ["-c", "IOHIDSystem", "-r", "-d", "1"], { timeout: 1500 });
|
|
511
|
+
const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-c", "IOHIDSystem", "-r", "-d", "1"], { timeout: 1500 });
|
|
512
512
|
const m = stdout.match(/"HIDIdleTime"\s*=\s*(\d+)/);
|
|
513
513
|
if (!m) return Infinity;
|
|
514
514
|
return parseInt(m[1], 10) / 1e9; // nanoseconds → seconds
|
|
@@ -3154,7 +3154,7 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3154
3154
|
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
3155
3155
|
).catch(() => null) : null;
|
|
3156
3156
|
// Window IDs are OS-assigned integers — reject anything non-numeric before it reaches
|
|
3157
|
-
// `do shell script "screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
|
|
3157
|
+
// `do shell script "/usr/sbin/screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
|
|
3158
3158
|
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
3159
3159
|
|
|
3160
3160
|
// On macOS Tahoe, screencapture -l may briefly steal focus.
|
|
@@ -3178,9 +3178,10 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3178
3178
|
);
|
|
3179
3179
|
try {
|
|
3180
3180
|
await new Promise((r) => setTimeout(r, 500));
|
|
3181
|
-
//
|
|
3182
|
-
|
|
3183
|
-
|
|
3181
|
+
// Route capture through the TCC-granted helper (NSAppleScript → do shell script):
|
|
3182
|
+
// under the launchd daemon, node has no Screen Recording grant — the helper does.
|
|
3183
|
+
await osascriptFast(
|
|
3184
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3184
3185
|
{ timeout: 15000 }
|
|
3185
3186
|
);
|
|
3186
3187
|
} finally {
|
|
@@ -3192,13 +3193,13 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
3192
3193
|
} else {
|
|
3193
3194
|
// Try direct execFile first (works if VS Code has Screen Recording permission)
|
|
3194
3195
|
try {
|
|
3195
|
-
await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
3196
|
+
await execFileAsync("/usr/sbin/screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
3196
3197
|
const testData = await readFile(tmpFile);
|
|
3197
3198
|
if (testData.length < 100) throw new Error("empty");
|
|
3198
3199
|
} catch (_) {
|
|
3199
|
-
// Fallback:
|
|
3200
|
-
await
|
|
3201
|
-
`do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3200
|
+
// Fallback: do shell script via the TCC-granted helper (node lacks Screen Recording under launchd)
|
|
3201
|
+
await osascriptFast(
|
|
3202
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3202
3203
|
{ timeout: 15000 }
|
|
3203
3204
|
);
|
|
3204
3205
|
}
|
|
@@ -3340,8 +3341,17 @@ export async function screenshotElement({ selector }) {
|
|
|
3340
3341
|
);
|
|
3341
3342
|
if (!bounds) throw new Error(typeof result === 'string' && result.startsWith('Element') ? result : 'Element not found for screenshot');
|
|
3342
3343
|
|
|
3343
|
-
// Full window screenshot then crop with sips
|
|
3344
|
-
|
|
3344
|
+
// Full window screenshot then crop with sips. Direct execFile works when this process
|
|
3345
|
+
// has Screen Recording (stdio mode); under the launchd daemon it doesn't — fall back
|
|
3346
|
+
// to the TCC-granted helper.
|
|
3347
|
+
try {
|
|
3348
|
+
await execFileAsync("/usr/sbin/screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
|
|
3349
|
+
} catch {
|
|
3350
|
+
await osascriptFast(
|
|
3351
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
3352
|
+
{ timeout: 15000 }
|
|
3353
|
+
);
|
|
3354
|
+
}
|
|
3345
3355
|
const { x, y, w, h, dpr = 1 } = JSON.parse(bounds);
|
|
3346
3356
|
// Use sips to crop (macOS built-in). Use the DYNAMIC toolbar height — Sequoia+ chrome is
|
|
3347
3357
|
// ~90px, not 74, so a hardcoded 74 left element screenshots vertically offset. Fall back
|
|
@@ -4291,76 +4301,58 @@ export async function clearConsoleCapture() {
|
|
|
4291
4301
|
export async function savePDF({ path: pdfPath }) {
|
|
4292
4302
|
await refreshTargetWindow();
|
|
4293
4303
|
_validateFilePath(pdfPath); // allowlist (/Users//tmp//var-folders) + block sensitive paths — prevents arbitrary overwrite
|
|
4294
|
-
// NO focus stealing —
|
|
4304
|
+
// NO app focus stealing — tab selection, window resize and screencapture all stay
|
|
4305
|
+
// inside our window; the user's frontmost app is never activated.
|
|
4295
4306
|
|
|
4296
|
-
// Step 1: Get full page dimensions
|
|
4297
|
-
const dims = await runJS("JSON.stringify({h:document.documentElement.scrollHeight,w:document.documentElement.scrollWidth})");
|
|
4298
|
-
const { h, w } = JSON.parse(dims);
|
|
4299
|
-
|
|
4300
|
-
// Step 2: Save current bounds and resize to capture full page
|
|
4301
|
-
const origBounds = await osascript(
|
|
4302
|
-
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
|
|
4303
|
-
);
|
|
4304
|
-
const captureHeight = Math.min(Number(h) + 100, 16000);
|
|
4305
|
-
await osascript(
|
|
4306
|
-
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${captureHeight}}`
|
|
4307
|
-
);
|
|
4308
|
-
await new Promise(r => setTimeout(r, 500)); // Let page reflow
|
|
4309
|
-
|
|
4310
|
-
// Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
|
|
4311
|
-
const windowIdRaw = await osascript(
|
|
4312
|
-
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
4313
|
-
);
|
|
4314
|
-
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
4315
|
-
if (!windowId) throw new Error("Cannot get Safari window ID for PDF capture");
|
|
4316
4307
|
const tmpPng = join(tmpdir(), `safari-mcp-pdf-${Date.now()}.png`);
|
|
4317
|
-
|
|
4318
|
-
|
|
4308
|
+
// screencapture -l grabs the window's SELECTED tab — front ours for the duration
|
|
4309
|
+
// (selection restored by _withTargetTabFronted), otherwise the PDF shows whatever
|
|
4310
|
+
// tab the user last left selected in that window.
|
|
4311
|
+
await _withTargetTabFronted(async () => {
|
|
4312
|
+
// Step 1: Get full page dimensions
|
|
4313
|
+
const dims = await runJS("JSON.stringify({h:document.documentElement.scrollHeight,w:document.documentElement.scrollWidth})");
|
|
4314
|
+
const { h, w } = JSON.parse(dims);
|
|
4315
|
+
|
|
4316
|
+
// Step 2: Save current bounds and resize to capture full page
|
|
4317
|
+
const origBounds = await osascript(
|
|
4318
|
+
`tell application "Safari" to return bounds of ${getTargetWindowRef()}`
|
|
4319
|
+
);
|
|
4320
|
+
const captureHeight = Math.min(Number(h) + 100, 16000);
|
|
4319
4321
|
await osascript(
|
|
4320
|
-
`
|
|
4321
|
-
{ timeout: 15000 }
|
|
4322
|
+
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${captureHeight}}`
|
|
4322
4323
|
);
|
|
4323
|
-
|
|
4324
|
-
// Restore bounds on failure
|
|
4325
|
-
await osascript(`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`).catch(() => {});
|
|
4326
|
-
throw new Error(`PDF screenshot capture failed: ${err.message}`);
|
|
4327
|
-
}
|
|
4324
|
+
await new Promise(r => setTimeout(r, 500)); // Let page reflow
|
|
4328
4325
|
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4326
|
+
// Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
|
|
4327
|
+
const windowIdRaw = await osascript(
|
|
4328
|
+
`tell application "Safari" to return id of ${getTargetWindowRef()}`
|
|
4329
|
+
);
|
|
4330
|
+
const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
|
|
4331
|
+
if (!windowId) throw new Error("Cannot get Safari window ID for PDF capture");
|
|
4332
|
+
try {
|
|
4333
|
+
// Route capture through the TCC-granted helper (NSAppleScript → do shell script):
|
|
4334
|
+
// under the launchd daemon, node has no Screen Recording grant — the helper does.
|
|
4335
|
+
await osascriptFast(
|
|
4336
|
+
`do shell script "/usr/sbin/screencapture -l${windowId} -o -x '${tmpPng}'"`,
|
|
4337
|
+
{ timeout: 15000 }
|
|
4338
|
+
);
|
|
4339
|
+
} catch (err) {
|
|
4340
|
+
// Restore bounds on failure
|
|
4341
|
+
await osascript(`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`).catch(() => {});
|
|
4342
|
+
throw new Error(`PDF screenshot capture failed: ${err.message}`);
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
// Step 4: Restore original bounds
|
|
4346
|
+
await osascript(
|
|
4347
|
+
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
|
|
4348
|
+
).catch(() => {});
|
|
4349
|
+
});
|
|
4333
4350
|
|
|
4334
|
-
// Step 5: Convert screenshot to PDF
|
|
4335
|
-
//
|
|
4336
|
-
//
|
|
4351
|
+
// Step 5: Convert screenshot to PDF with sips (macOS built-in). Replaces the previous
|
|
4352
|
+
// python3+Quartz converter: current macOS ships pyobjc for neither the system nor the
|
|
4353
|
+
// homebrew python3, so any bare `python3` died with ModuleNotFoundError: Quartz.
|
|
4337
4354
|
try {
|
|
4338
|
-
await execFileAsync("
|
|
4339
|
-
import sys
|
|
4340
|
-
from Quartz import CGImageSourceCreateWithURL, CGImageSourceCreateImageAtIndex, CGImageGetWidth, CGImageGetHeight, CGPDFContextCreateWithURL, CGRectMake, CGPDFContextBeginPage, CGPDFContextEndPage, CGContextDrawImage
|
|
4341
|
-
from CoreFoundation import CFURLCreateFromFileSystemRepresentation
|
|
4342
|
-
|
|
4343
|
-
png_path = sys.argv[1].encode('utf-8')
|
|
4344
|
-
pdf_path = sys.argv[2].encode('utf-8')
|
|
4345
|
-
|
|
4346
|
-
src_url = CFURLCreateFromFileSystemRepresentation(None, png_path, len(png_path), False)
|
|
4347
|
-
img_src = CGImageSourceCreateWithURL(src_url, None)
|
|
4348
|
-
if not img_src:
|
|
4349
|
-
print("ERROR: failed to read screenshot", file=sys.stderr); sys.exit(1)
|
|
4350
|
-
img = CGImageSourceCreateImageAtIndex(img_src, 0, None)
|
|
4351
|
-
if not img:
|
|
4352
|
-
print("ERROR: failed to decode image", file=sys.stderr); sys.exit(1)
|
|
4353
|
-
w = CGImageGetWidth(img)
|
|
4354
|
-
h = CGImageGetHeight(img)
|
|
4355
|
-
|
|
4356
|
-
pdf_url = CFURLCreateFromFileSystemRepresentation(None, pdf_path, len(pdf_path), False)
|
|
4357
|
-
ctx = CGPDFContextCreateWithURL(pdf_url, CGRectMake(0, 0, w, h), None)
|
|
4358
|
-
CGPDFContextBeginPage(ctx, None)
|
|
4359
|
-
CGContextDrawImage(ctx, CGRectMake(0, 0, w, h), img)
|
|
4360
|
-
CGPDFContextEndPage(ctx)
|
|
4361
|
-
del ctx
|
|
4362
|
-
print(f"OK {w}x{h}")
|
|
4363
|
-
`.trim(), tmpPng, pdfPath], { timeout: 15000 });
|
|
4355
|
+
await execFileAsync("/usr/bin/sips", ["-s", "format", "pdf", tmpPng, "--out", pdfPath], { timeout: 15000 });
|
|
4364
4356
|
} catch (err) {
|
|
4365
4357
|
throw new Error(`PDF conversion failed: ${err.message}`);
|
|
4366
4358
|
} finally {
|