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.
Files changed (3) hide show
  1. package/README.md +32 -0
  2. package/package.json +1 -1
  3. 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.8",
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
- // Use do shell script to inherit osascript's Screen Recording permission
3182
- await osascript(
3183
- `do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
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: use do shell script (osascript may have permission)
3200
- await osascript(
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
- await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
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 — uses screencapture + Python Quartz to generate PDF
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
- try {
4318
- // Use do shell script to inherit osascript's Screen Recording permission
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
- `do shell script "screencapture -l${windowId} -o -x '${tmpPng}'"`,
4321
- { timeout: 15000 }
4322
+ `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${captureHeight}}`
4322
4323
  );
4323
- } catch (err) {
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
- // Step 4: Restore original bounds
4330
- await osascript(
4331
- `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
4332
- ).catch(() => {});
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 using Python3 + macOS Quartz (no external deps).
4335
- // Paths are passed as argv — NOT interpolated into the source — so there is no shell
4336
- // or Python string-escaping to get wrong, and no code-injection surface.
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("python3", ["-c", `
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 {