safari-mcp 2.22.13 → 2.22.15
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/package.json +1 -1
- package/safari.js +42 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.22.
|
|
3
|
+
"version": "2.22.15",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import { tmpdir, homedir } from "node:os";
|
|
9
9
|
import { join, dirname, resolve as resolvePath } from "node:path";
|
|
10
10
|
import { readFile, writeFile, unlink, appendFile, mkdir } from "node:fs/promises";
|
|
11
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
11
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import { VIEWPORT_SCRIPT, SAFE_AREA_SCRIPT, PWA_SCRIPT, WEBKIT_COMPAT_SCRIPT, ALL_SHEETS_FN } from "./injected-validators.js";
|
|
@@ -998,7 +998,7 @@ function _windowById(id) {
|
|
|
998
998
|
// Page JavaScript that answers "1" when the page carries exactly `marker`: in window.name, or in
|
|
999
999
|
// window.__mcpTabMarker once a page has taken window.name over.
|
|
1000
1000
|
function _markerCheckJS(marker) {
|
|
1001
|
-
const safeMarker = String(marker).replace(/'/g, "\\'");
|
|
1001
|
+
const safeMarker = String(marker).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1002
1002
|
return `(function(){try{return (window.name==='${safeMarker}'||window.__mcpTabMarker==='${safeMarker}')?'1':'0'}catch(e){return '0'}})()`;
|
|
1003
1003
|
}
|
|
1004
1004
|
|
|
@@ -1426,9 +1426,16 @@ export const SCREEN_LOCKED_MSG =
|
|
|
1426
1426
|
"safari_type_text / safari_press_key, and safari_upload_file without forceNative.";
|
|
1427
1427
|
|
|
1428
1428
|
export async function isScreenLocked() {
|
|
1429
|
-
|
|
1430
|
-
"ioreg -n Root -d1 -r -a
|
|
1431
|
-
|
|
1429
|
+
try {
|
|
1430
|
+
const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-n", "Root", "-d1", "-r", "-a"], { timeout: 2000 });
|
|
1431
|
+
const matches = [...String(stdout).matchAll(/<key>\s*(?:CGSSessionScreenIsLocked|IOConsoleLocked)\s*<\/key>\s*<(true|false)\s*\/>/g)];
|
|
1432
|
+
if (matches.length) return matches.some(match => match[1] === "true");
|
|
1433
|
+
// ioreg omits the key on an unlocked session. A present but unreadable value or
|
|
1434
|
+
// a broken probe must fail toward blocked input, never toward CGEvent.
|
|
1435
|
+
return /<key>\s*(?:CGSSessionScreenIsLocked|IOConsoleLocked)\s*<\/key>/.test(String(stdout));
|
|
1436
|
+
} catch {
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1432
1439
|
}
|
|
1433
1440
|
|
|
1434
1441
|
export async function assertScreenUnlocked(probe = isScreenLocked) {
|
|
@@ -3235,7 +3242,7 @@ export async function selectOption({ selector, value, ref }) {
|
|
|
3235
3242
|
const val = String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3236
3243
|
let finder;
|
|
3237
3244
|
if (ref) {
|
|
3238
|
-
finder = `mcpFindRef('${
|
|
3245
|
+
finder = `mcpFindRef('${escJsSingleQuote(ref)}')`;
|
|
3239
3246
|
} else if (selector) {
|
|
3240
3247
|
const sel = escJsSingleQuote(selector);
|
|
3241
3248
|
finder = `(document.querySelector('${sel}')||mcpQuerySelectorDeep('${sel}'))`;
|
|
@@ -3257,7 +3264,7 @@ export async function reactSelectSet({ selector, ref, value }) {
|
|
|
3257
3264
|
if (value === undefined || value === null) throw new Error("reactSelectSet requires 'value' (option label)");
|
|
3258
3265
|
let finder;
|
|
3259
3266
|
if (ref) {
|
|
3260
|
-
const safeRef =
|
|
3267
|
+
const safeRef = escJsSingleQuote(ref);
|
|
3261
3268
|
finder = `mcpFindRef('${safeRef}')`;
|
|
3262
3269
|
} else if (selector) {
|
|
3263
3270
|
const sel = escJsSingleQuote(selector);
|
|
@@ -3274,7 +3281,7 @@ export async function reactSelectListOptions({ selector, ref }) {
|
|
|
3274
3281
|
await ensureHelpers();
|
|
3275
3282
|
let finder;
|
|
3276
3283
|
if (ref) {
|
|
3277
|
-
const safeRef =
|
|
3284
|
+
const safeRef = escJsSingleQuote(ref);
|
|
3278
3285
|
finder = `mcpFindRef('${safeRef}')`;
|
|
3279
3286
|
} else if (selector) {
|
|
3280
3287
|
const sel = escJsSingleQuote(selector);
|
|
@@ -3511,7 +3518,7 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
3511
3518
|
|
|
3512
3519
|
// Non-modifier keys: pure JavaScript (no System Events)
|
|
3513
3520
|
const jsKey = jsKeyMap[k] || key;
|
|
3514
|
-
const safeKey = jsKey
|
|
3521
|
+
const safeKey = escJsSingleQuote(jsKey);
|
|
3515
3522
|
// W3C `code` values: special keys are NOT "Key"-prefixed ("Enter", "ArrowUp", ...);
|
|
3516
3523
|
// only letters are ("KeyA"), digits are "Digit1". Apps that route on event.code
|
|
3517
3524
|
// (Notion, Monaco, Google Docs) ignore a bogus "KeyEnter".
|
|
@@ -3526,7 +3533,7 @@ export async function pressKey({ key, modifiers = [] }) {
|
|
|
3526
3533
|
|| (/^[a-z]$/i.test(jsKey) ? "Key" + jsKey.toUpperCase()
|
|
3527
3534
|
: /^[0-9]$/.test(jsKey) ? "Digit" + jsKey
|
|
3528
3535
|
: jsKey);
|
|
3529
|
-
const safeCode = jsCode
|
|
3536
|
+
const safeCode = escJsSingleQuote(jsCode);
|
|
3530
3537
|
const shiftKey = hasShift;
|
|
3531
3538
|
const altKey = modifiers.some((m) => m.toLowerCase() === "alt");
|
|
3532
3539
|
|
|
@@ -3687,7 +3694,7 @@ export async function replaceEditorContent({ text }) {
|
|
|
3687
3694
|
// then call onChange(text) to sync React. Works for Airtable-style embeds
|
|
3688
3695
|
// and is a no-op on plain Monaco (VS Code web, GitHub) where setValue
|
|
3689
3696
|
// already covers the state.
|
|
3690
|
-
const preflightFirstLine = text.split('\n')[0].slice(0, 20)
|
|
3697
|
+
const preflightFirstLine = escJsSingleQuote(text.split('\n')[0].slice(0, 20));
|
|
3691
3698
|
const monacoPreflight = await runJS(
|
|
3692
3699
|
`(function(){
|
|
3693
3700
|
var m = (typeof monaco !== 'undefined') ? monaco : window.monaco;
|
|
@@ -5174,7 +5181,7 @@ export async function uploadFile({ selector, filePath, forceNative = false, veri
|
|
|
5174
5181
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
5175
5182
|
};
|
|
5176
5183
|
const mime = mimeMap[ext] || "application/octet-stream";
|
|
5177
|
-
const safeName = fileName
|
|
5184
|
+
const safeName = escJsSingleQuote(fileName);
|
|
5178
5185
|
|
|
5179
5186
|
// forceNative: skip synthetic injection entirely. Attempting it first would leave a stray
|
|
5180
5187
|
// media item behind on sites that register the pickup in their UI without ingesting it
|
|
@@ -5406,7 +5413,7 @@ export async function pasteImageFromFile({ filePath, locate = null }) {
|
|
|
5406
5413
|
// Read image as base64
|
|
5407
5414
|
const fileData = await readFile(filePath);
|
|
5408
5415
|
const base64 = fileData.toString("base64");
|
|
5409
|
-
const fileName = filePath.split("/").pop()
|
|
5416
|
+
const fileName = escJsSingleQuote(filePath.split("/").pop());
|
|
5410
5417
|
|
|
5411
5418
|
// Use runJSLarge — images are often >260KB as base64
|
|
5412
5419
|
const result = await runJSLarge(
|
|
@@ -5658,7 +5665,7 @@ export function getNextSnapshotGen() { return _snapshotGen++; }
|
|
|
5658
5665
|
export async function takeSnapshot({ selector, _gen } = {}) {
|
|
5659
5666
|
// Use provided gen (from tool path) or allocate a new one (direct call)
|
|
5660
5667
|
const gen = _gen != null ? _gen : _snapshotGen++;
|
|
5661
|
-
const root = selector ? `document.querySelector('${selector
|
|
5668
|
+
const root = selector ? `document.querySelector('${escJsSingleQuote(selector)}')` : "document.body";
|
|
5662
5669
|
|
|
5663
5670
|
const result = await runJS(
|
|
5664
5671
|
`(function(){
|
|
@@ -5890,7 +5897,7 @@ export async function runScript({ steps, onStep, actions: overrides = {} }) {
|
|
|
5890
5897
|
// ========== ACCESSIBILITY SNAPSHOT ==========
|
|
5891
5898
|
|
|
5892
5899
|
export async function getAccessibilityTree({ selector, maxDepth = 5 }) {
|
|
5893
|
-
const sel = selector ? `'${selector
|
|
5900
|
+
const sel = selector ? `'${escJsSingleQuote(selector)}'` : "null";
|
|
5894
5901
|
return runJS(
|
|
5895
5902
|
`(function(){
|
|
5896
5903
|
function buildTree(el, depth) {
|
|
@@ -6235,7 +6242,7 @@ export async function clearNetworkCapture() {
|
|
|
6235
6242
|
}
|
|
6236
6243
|
|
|
6237
6244
|
export async function getNetworkDetails({ limit = 50, filter } = {}) {
|
|
6238
|
-
const filterStr = filter ? `.filter(function(r){return r.url.includes('${filter
|
|
6245
|
+
const filterStr = filter ? `.filter(function(r){return r.url.includes('${escJsSingleQuote(filter)}')})` : "";
|
|
6239
6246
|
return runJS(
|
|
6240
6247
|
`JSON.stringify((window.__mcp_network||[])${filterStr}.slice(-${Number(limit)}))`
|
|
6241
6248
|
);
|
|
@@ -6343,7 +6350,7 @@ export async function throttleNetwork({ profile, latency, downloadKbps, uploadKb
|
|
|
6343
6350
|
// ========== CONSOLE FILTER ==========
|
|
6344
6351
|
|
|
6345
6352
|
export async function getConsoleByLevel({ level }) {
|
|
6346
|
-
const safeLevel = level
|
|
6353
|
+
const safeLevel = escJsSingleQuote(level);
|
|
6347
6354
|
return runJS(
|
|
6348
6355
|
`JSON.stringify((window.__mcp_console||[]).filter(function(m){return m.level==='${safeLevel}'}))`
|
|
6349
6356
|
);
|
|
@@ -6352,7 +6359,7 @@ export async function getConsoleByLevel({ level }) {
|
|
|
6352
6359
|
// ========== DATA EXTRACTION ==========
|
|
6353
6360
|
|
|
6354
6361
|
export async function extractTables({ selector, limit = 10 }) {
|
|
6355
|
-
const sel = selector ? `'${selector
|
|
6362
|
+
const sel = selector ? `'${escJsSingleQuote(selector)}'` : "'table'";
|
|
6356
6363
|
return runJS(
|
|
6357
6364
|
`(function(){
|
|
6358
6365
|
var tables = [...document.querySelectorAll(${sel})].slice(0, ${Number(limit)});
|
|
@@ -6417,7 +6424,7 @@ export async function extractImages({ limit = 50 }) {
|
|
|
6417
6424
|
|
|
6418
6425
|
export async function extractLinks({ limit = 100, filter }) {
|
|
6419
6426
|
const filterStr = filter
|
|
6420
|
-
? `.filter(function(a){return a.href.includes('${filter
|
|
6427
|
+
? `.filter(function(a){return a.href.includes('${escJsSingleQuote(filter)}')||a.textContent.includes('${escJsSingleQuote(filter)}')})`
|
|
6421
6428
|
: "";
|
|
6422
6429
|
return runJS(
|
|
6423
6430
|
`JSON.stringify([...document.querySelectorAll('a[href]')]${filterStr}.slice(0,${Number(limit)}).map(function(a){
|
|
@@ -6469,8 +6476,8 @@ export async function getComputedStyles({ selector, properties }) {
|
|
|
6469
6476
|
// ========== INDEXEDDB ==========
|
|
6470
6477
|
|
|
6471
6478
|
export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
|
|
6472
|
-
const safeDb = dbName
|
|
6473
|
-
const safeStore = storeName
|
|
6479
|
+
const safeDb = escJsSingleQuote(dbName);
|
|
6480
|
+
const safeStore = escJsSingleQuote(storeName);
|
|
6474
6481
|
// `do JavaScript` can't await a Promise — route async work through the Node-side poller.
|
|
6475
6482
|
return _evaluateAsync(
|
|
6476
6483
|
`(async function(){
|
|
@@ -6610,6 +6617,17 @@ export function macosCompatNote(productVersion) {
|
|
|
6610
6617
|
return { version: raw, major, risky, line };
|
|
6611
6618
|
}
|
|
6612
6619
|
|
|
6620
|
+
// A Homebrew upgrade deletes the node binary a long-running server still executes, and System
|
|
6621
|
+
// Settings cannot grant anything to a file that is gone. Seen on 9.10.26: node 26.9.0 was replaced
|
|
6622
|
+
// by 26.11.0 under a LaunchAgent daemon started two days earlier, and doctor went from 6/6 to 3/6
|
|
6623
|
+
// (Apple Events -1743, both helper permissions false). Only a restart onto the current node helps.
|
|
6624
|
+
export function staleNodeNote(execPath = process.execPath, exists = existsSync) {
|
|
6625
|
+
if (exists(execPath)) return null;
|
|
6626
|
+
return `⚠ This server still runs node from ${execPath}, which no longer exists: node was upgraded or removed while the server kept running. ` +
|
|
6627
|
+
"macOS permission checks can fail for a binary that is gone, and System Settings cannot grant anything to it. " +
|
|
6628
|
+
"Restart the server on the current node, then grant the permissions to the new binary if macOS asks.";
|
|
6629
|
+
}
|
|
6630
|
+
|
|
6613
6631
|
export async function doctor() {
|
|
6614
6632
|
const checks = [];
|
|
6615
6633
|
const add = (ok, label, detail, fix) => checks.push({ ok, label, detail, fix: ok ? null : fix });
|
|
@@ -6657,7 +6675,7 @@ export async function doctor() {
|
|
|
6657
6675
|
// Homebrew node upgrade changes that path, so the grant has to follow the new binary.
|
|
6658
6676
|
add(!!pf && pf.screenRecording === true, "Screen Recording (screenshots)",
|
|
6659
6677
|
pf ? (pf.screenRecording ? "permitted" : "NOT permitted — screenshots taken through AppleScript and safari_save_pdf fail (extension screenshots don't need it)") : "unknown (helper not responding)",
|
|
6660
|
-
`System Settings > Privacy & Security > Screen & System Audio Recording → enable ${process.ppid
|
|
6678
|
+
`System Settings > Privacy & Security > Screen & System Audio Recording → enable ${process.ppid !== 1 ? "the terminal/IDE that launched this server" : staleNodeNote() ? "the node this server runs on after a restart (see the warning above)" : `node at ${process.execPath} (this server runs under launchd)`}.`);
|
|
6661
6679
|
|
|
6662
6680
|
// 6. Helper codesign identity — a stale/ad-hoc id breaks the Accessibility grant on reinstall
|
|
6663
6681
|
let idOk = false, idDetail = "";
|
|
@@ -6685,6 +6703,8 @@ export async function doctor() {
|
|
|
6685
6703
|
const passed = checks.filter((c) => c.ok).length;
|
|
6686
6704
|
const lines = [`Safari MCP doctor — ${passed}/${checks.length} checks passed`, ""];
|
|
6687
6705
|
if (osLine) lines.push(osLine, "");
|
|
6706
|
+
const nodeNote = staleNodeNote();
|
|
6707
|
+
if (nodeNote) lines.push(nodeNote, "");
|
|
6688
6708
|
// Not a pass/fail check — a state the user has to be able to see here rather than dig out
|
|
6689
6709
|
// of the host's env, so "why did it touch my tab" has an answer in the same report (#92).
|
|
6690
6710
|
lines.push(
|