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.
@@ -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);
@@ -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.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", { script: args.script, ..._explicitReceipt(args) },
2921
- () => safari.evaluate(args)
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.19.1",
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",