safari-mcp 2.19.1 → 2.20.0
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 +90 -0
- package/extension/manifest.json +2 -2
- package/index.js +27 -2
- package/package.json +1 -1
package/extension/background.js
CHANGED
|
@@ -1116,6 +1116,10 @@ async function handleCommand(type, payload) {
|
|
|
1116
1116
|
// --- JavaScript Execution — multi-strategy to handle CSP restrictions ---
|
|
1117
1117
|
// Strategy 1: indirect eval (fast, works when CSP allows unsafe-eval)
|
|
1118
1118
|
// Strategy 2: script element injection (bypasses CSP in MAIN world context)
|
|
1119
|
+
case "list_frames": {
|
|
1120
|
+
return await listFrames(tabId);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1119
1123
|
case "evaluate": {
|
|
1120
1124
|
// Strategy 0: pages that stall injection outright. Every strategy below reaches
|
|
1121
1125
|
// the page through scripting.executeScript, which on business.facebook.com never
|
|
@@ -1124,6 +1128,12 @@ async function handleCommand(type, payload) {
|
|
|
1124
1128
|
// it FIRST once a tab is known to block injection, and fall back to it below
|
|
1125
1129
|
// when a fresh tab turns out to block it too.
|
|
1126
1130
|
const evalTabId = tabId || (await getActiveTab()).id;
|
|
1131
|
+
// A frame selector short-circuits every strategy below: those all target the
|
|
1132
|
+
// main frame, which is exactly what makes an embedded app unreachable.
|
|
1133
|
+
if (payload.frame !== undefined && payload.frame !== null && payload.frame !== "") {
|
|
1134
|
+
const _fid = await resolveFrameId(evalTabId, payload.frame);
|
|
1135
|
+
return await evaluateInFrame(evalTabId, _fid, payload.script);
|
|
1136
|
+
}
|
|
1127
1137
|
const viaBridge = async () => {
|
|
1128
1138
|
const r = await sendContentCommand(
|
|
1129
1139
|
evalTabId, "mcp-content-eval", { source: payload.script }, 10000
|
|
@@ -4170,6 +4180,86 @@ function _isFrameMiss(value) {
|
|
|
4170
4180
|
// Execute in ALL frames (including cross-origin iframes) and return the first real
|
|
4171
4181
|
// match. A top-frame `Element not found` is a semantic miss, not a result: allowing
|
|
4172
4182
|
// it to win used to hide valid matches in every child frame behind it.
|
|
4183
|
+
// --- Frame targeting -------------------------------------------------------
|
|
4184
|
+
// Every other command reaches only the tab's main frame, so a cross-origin
|
|
4185
|
+
// iframe — a micro-frontend app shell, an embedded checkout, GoHighLevel's
|
|
4186
|
+
// workflow builder — was effectively invisible: read_page returned the outer
|
|
4187
|
+
// shell's loader text and evaluate ran outside the app entirely. Safari's
|
|
4188
|
+
// scripting.executeScript can address one frame by id, so these expose the
|
|
4189
|
+
// frame list and let a script run inside a chosen frame.
|
|
4190
|
+
async function listFrames(tabId = null) {
|
|
4191
|
+
const id = tabId || (await getActiveTab()).id;
|
|
4192
|
+
const results = await _executeAllFrames(() => ({
|
|
4193
|
+
url: location.href,
|
|
4194
|
+
title: document.title,
|
|
4195
|
+
textLength: ((document.body && document.body.innerText) || "").length,
|
|
4196
|
+
}), [], id);
|
|
4197
|
+
return results.map((r, i) => (
|
|
4198
|
+
r && r.error
|
|
4199
|
+
? { index: i, frameId: Number.isInteger(r.frameId) ? r.frameId : null, error: String(r.error) }
|
|
4200
|
+
: { index: i, frameId: Number.isInteger(r && r.frameId) ? r.frameId : null, ...((r && r.result) || {}) }
|
|
4201
|
+
));
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
// Resolve a caller's frame selector to one concrete frameId. `frame` is either a
|
|
4205
|
+
// numeric frameId or a substring matched against each frame's URL. Ambiguity is
|
|
4206
|
+
// an error rather than a guess: silently picking one of several matching frames
|
|
4207
|
+
// is how an automation ends up acting on the wrong document.
|
|
4208
|
+
async function resolveFrameId(tabId, frame) {
|
|
4209
|
+
if (typeof frame === "number" && Number.isInteger(frame)) return frame;
|
|
4210
|
+
const needle = String(frame == null ? "" : frame).toLowerCase();
|
|
4211
|
+
if (!needle) throw new Error("frame must be a frameId number or a URL substring");
|
|
4212
|
+
const frames = await listFrames(tabId);
|
|
4213
|
+
const hits = frames.filter((f) => !f.error && typeof f.url === "string" && f.url.toLowerCase().includes(needle));
|
|
4214
|
+
if (hits.length === 0) {
|
|
4215
|
+
throw new Error("No frame matched " + JSON.stringify(String(frame)) + ". Frames present: " +
|
|
4216
|
+
(frames.map((f) => f.url || ("#" + f.index)).join(" | ") || "(none)"));
|
|
4217
|
+
}
|
|
4218
|
+
if (hits.length > 1) {
|
|
4219
|
+
throw new Error("Frame selector " + JSON.stringify(String(frame)) + " matched " + hits.length +
|
|
4220
|
+
" frames — narrow it: " + hits.map((f) => f.url).join(" | "));
|
|
4221
|
+
}
|
|
4222
|
+
if (!Number.isInteger(hits[0].frameId)) throw new Error("Matched frame has no stable frameId");
|
|
4223
|
+
return hits[0].frameId;
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
// Run one script inside a single frame. MAIN world first so the script sees the
|
|
4227
|
+
// page's own globals; ISOLATED as the fallback for frames whose CSP blocks eval —
|
|
4228
|
+
// it still reads and mutates the DOM, which is what clicking and filling need.
|
|
4229
|
+
async function evaluateInFrame(tabId, frameId, script) {
|
|
4230
|
+
const id = tabId || (await getActiveTab()).id;
|
|
4231
|
+
const runner = async (src) => {
|
|
4232
|
+
try {
|
|
4233
|
+
const result = await (0, eval)(src);
|
|
4234
|
+
if (result === undefined || result === null) return null;
|
|
4235
|
+
return typeof result === "object" ? JSON.stringify(result) : String(result);
|
|
4236
|
+
} catch (e) {
|
|
4237
|
+
const m = String(e && e.message);
|
|
4238
|
+
if (m.includes("unsafe-eval") || m.includes("trusted-types") || m.includes("Trusted Type")) {
|
|
4239
|
+
return "__CSP_BLOCKED__";
|
|
4240
|
+
}
|
|
4241
|
+
return "Error: " + m;
|
|
4242
|
+
}
|
|
4243
|
+
};
|
|
4244
|
+
const run = async (world) => {
|
|
4245
|
+
const results = await _withInjectionDeadline(browser.scripting.executeScript({
|
|
4246
|
+
target: { tabId: id, frameIds: [frameId] },
|
|
4247
|
+
world,
|
|
4248
|
+
func: runner,
|
|
4249
|
+
args: [script],
|
|
4250
|
+
}));
|
|
4251
|
+
const first = results[0];
|
|
4252
|
+
if (first && first.error) throw new Error(first.error);
|
|
4253
|
+
return first && first.result;
|
|
4254
|
+
};
|
|
4255
|
+
const mainResult = await run("MAIN");
|
|
4256
|
+
if (mainResult !== "__CSP_BLOCKED__") return mainResult;
|
|
4257
|
+
const isolated = await run("ISOLATED");
|
|
4258
|
+
return isolated === "__CSP_BLOCKED__"
|
|
4259
|
+
? "Error: eval is blocked by CSP in both MAIN and ISOLATED worlds for this frame"
|
|
4260
|
+
: isolated;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4173
4263
|
async function execInAllFrames(func, args = [], tabId = null) {
|
|
4174
4264
|
try {
|
|
4175
4265
|
const results = await _executeAllFrames(func, args, tabId);
|
package/extension/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Safari MCP Bridge",
|
|
4
4
|
"description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
|
|
5
|
-
"version": "2.10.
|
|
5
|
+
"version": "2.10.11",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|
|
@@ -65,4 +65,4 @@
|
|
|
65
65
|
"run_at": "document_start"
|
|
66
66
|
}
|
|
67
67
|
]
|
|
68
|
-
}
|
|
68
|
+
}
|
package/index.js
CHANGED
|
@@ -1445,6 +1445,7 @@ const _noOwnershipCheck = new Set([
|
|
|
1445
1445
|
"reload_extension",
|
|
1446
1446
|
// Read-only — don't modify the page
|
|
1447
1447
|
"read_page", "get_source", "snapshot", "accessibility_snapshot",
|
|
1448
|
+
"list_frames",
|
|
1448
1449
|
"get_element", "query_all", "screenshot", "screenshot_element",
|
|
1449
1450
|
"get_console", "list_console_messages", "start_console",
|
|
1450
1451
|
"get_network", "list_network_requests", "start_network_capture",
|
|
@@ -2906,6 +2907,23 @@ server.tool(
|
|
|
2906
2907
|
}
|
|
2907
2908
|
);
|
|
2908
2909
|
|
|
2910
|
+
// ========== FRAMES ==========
|
|
2911
|
+
|
|
2912
|
+
server.tool(
|
|
2913
|
+
"safari_list_frames",
|
|
2914
|
+
"List every document in the tab — the main page plus each iframe — with its frameId, URL and text length. Use when a page's content lives in a cross-origin iframe (an embedded app, a micro-frontend shell, a payment field): safari_read_page and safari_evaluate see only the main document there and come back empty or with a loader. Pass the frameId or a URL substring as safari_evaluate's `frame` to run inside it.",
|
|
2915
|
+
{
|
|
2916
|
+
receipt: z.string().optional().describe("Opaque extension-issued tab receipt"),
|
|
2917
|
+
},
|
|
2918
|
+
async (args) => {
|
|
2919
|
+
const result = await extensionOrFallback(
|
|
2920
|
+
"list_frames", { ..._explicitReceipt(args) },
|
|
2921
|
+
() => { throw new Error("safari_list_frames needs the extension — AppleScript cannot enumerate frames. Check safari_doctor."); }
|
|
2922
|
+
);
|
|
2923
|
+
return { content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }] };
|
|
2924
|
+
}
|
|
2925
|
+
);
|
|
2926
|
+
|
|
2909
2927
|
// ========== EVALUATE JAVASCRIPT ==========
|
|
2910
2928
|
|
|
2911
2929
|
server.tool(
|
|
@@ -2913,12 +2931,19 @@ server.tool(
|
|
|
2913
2931
|
"Execute JavaScript in the current page (a returned Promise is awaited — fetch/timers work in background tabs; requestAnimationFrame never fires there). Automatically falls back to AppleScript when CSP blocks execution (e.g. Google Search Console, LinkedIn). For reading data, prefer safari_read_page or safari_snapshot. For interactions, prefer safari_click/fill with refs.",
|
|
2914
2932
|
{
|
|
2915
2933
|
script: z.string().describe("JavaScript code to execute"),
|
|
2934
|
+
frame: z.union([z.string(), z.number()]).optional().describe("Run inside a sub-frame instead of the main document: a frameId from safari_list_frames, or a substring of the frame's URL (must match exactly one). Needed for cross-origin iframes — micro-frontend app shells, embedded checkouts — where the main document only holds a loader."),
|
|
2916
2935
|
receipt: z.string().optional().describe("Opaque extension-issued tab receipt — pass the one safari_new_tab returned to keep targeting that tab after an MCP reconnect"),
|
|
2917
2936
|
},
|
|
2918
2937
|
async (args) => {
|
|
2919
2938
|
const result = await extensionOrFallback(
|
|
2920
|
-
"evaluate",
|
|
2921
|
-
(
|
|
2939
|
+
"evaluate",
|
|
2940
|
+
{ script: args.script, ...(args.frame === undefined ? {} : { frame: args.frame }), ..._explicitReceipt(args) },
|
|
2941
|
+
() => {
|
|
2942
|
+
if (args.frame !== undefined) {
|
|
2943
|
+
throw new Error("safari_evaluate: frame targeting needs the extension — AppleScript reaches only the main document. Check the extension is connected (safari_doctor).");
|
|
2944
|
+
}
|
|
2945
|
+
return safari.evaluate(args);
|
|
2946
|
+
}
|
|
2922
2947
|
);
|
|
2923
2948
|
return { content: [{ type: "text", text: (typeof result === 'string' ? result : JSON.stringify(result)) || "(no return value)" }] };
|
|
2924
2949
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 97 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|