safari-mcp 2.19.0 → 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.
@@ -732,7 +732,16 @@ async function pollForCommands(pollGeneration) {
732
732
  _bridgeFetch(`${bridgeUrl}/heartbeat`, { method: "POST" }).catch(() => {});
733
733
  }, 5000);
734
734
  await previousExecution.catch(() => {});
735
- await executeAndReply(msg, bridgeUrl);
735
+ // 7.9.26: a command whose handleCommand never settles (a tab that never answers,
736
+ // a runtime call that hangs) used to park this loop for good: the beat kept the
737
+ // host believing we were alive, /poll went silent for hours, connectToServer
738
+ // waited on the same claim, and only a Safari restart brought the profile back.
739
+ // Bound it: past the host's hard ceiling we answer with an error, release the
740
+ // claim and keep polling; the abandoned promise may settle later into the void.
741
+ await Promise.race([
742
+ executeAndReply(msg, bridgeUrl),
743
+ _wedgedCommandGuard(msg, bridgeUrl),
744
+ ]);
736
745
  }
737
746
  } finally {
738
747
  if (beat) clearInterval(beat);
@@ -764,6 +773,33 @@ async function pollForCommands(pollGeneration) {
764
773
  }
765
774
  }
766
775
 
776
+ // Ceiling for one command. The host's own hard deadline is max(4×timeout, 180s) and its
777
+ // longest tool timeout is 75s, so nothing legitimate is still awaited after five minutes.
778
+ const _WEDGED_COMMAND_MS = 330000;
779
+ function _wedgedCommandGuard(msg, bridgeUrl) {
780
+ return new Promise((resolve) => {
781
+ setTimeout(async () => {
782
+ console.log("Safari MCP: command wedged, releasing the poll loop", msg && msg.type);
783
+ try {
784
+ await _bridgeFetch(`${bridgeUrl}/result`, {
785
+ method: "POST",
786
+ headers: { "Content-Type": "application/json" },
787
+ body: JSON.stringify({
788
+ type: "response",
789
+ id: msg.id,
790
+ result: null,
791
+ error: `command ${msg.type} did not settle within ${_WEDGED_COMMAND_MS}ms`,
792
+ }),
793
+ signal: AbortSignal.timeout(5000),
794
+ });
795
+ } catch (e) {
796
+ /* the host most likely expired it already */
797
+ }
798
+ resolve();
799
+ }, _WEDGED_COMMAND_MS);
800
+ });
801
+ }
802
+
767
803
  // ========== SHARED: Execute command and send response ==========
768
804
 
769
805
  async function executeAndReply(msg, bridgeUrl = HTTP_URL) {
@@ -1080,6 +1116,10 @@ async function handleCommand(type, payload) {
1080
1116
  // --- JavaScript Execution — multi-strategy to handle CSP restrictions ---
1081
1117
  // Strategy 1: indirect eval (fast, works when CSP allows unsafe-eval)
1082
1118
  // Strategy 2: script element injection (bypasses CSP in MAIN world context)
1119
+ case "list_frames": {
1120
+ return await listFrames(tabId);
1121
+ }
1122
+
1083
1123
  case "evaluate": {
1084
1124
  // Strategy 0: pages that stall injection outright. Every strategy below reaches
1085
1125
  // the page through scripting.executeScript, which on business.facebook.com never
@@ -1088,6 +1128,12 @@ async function handleCommand(type, payload) {
1088
1128
  // it FIRST once a tab is known to block injection, and fall back to it below
1089
1129
  // when a fresh tab turns out to block it too.
1090
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
+ }
1091
1137
  const viaBridge = async () => {
1092
1138
  const r = await sendContentCommand(
1093
1139
  evalTabId, "mcp-content-eval", { source: payload.script }, 10000
@@ -4134,6 +4180,86 @@ function _isFrameMiss(value) {
4134
4180
  // Execute in ALL frames (including cross-origin iframes) and return the first real
4135
4181
  // match. A top-frame `Element not found` is a semantic miss, not a result: allowing
4136
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
+
4137
4263
  async function execInAllFrames(func, args = [], tabId = null) {
4138
4264
  try {
4139
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.9",
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
@@ -908,15 +908,23 @@ try {
908
908
  const heartbeatingWorkerId = _requireActiveHttpWorker(req, res);
909
909
  if (!heartbeatingWorkerId) return;
910
910
  _extensionLastHeartbeat = Date.now();
911
- _extensionLastPollTime = Date.now();
912
911
  // Re-arm in-flight deadlines: the worker told us it is still on the command.
913
912
  // hardDeadline inside armTimer keeps this from extending indefinitely.
913
+ let liveInFlight = false;
914
914
  for (const pending of _pendingRequests.values()) {
915
915
  if (pending.dispatchedWorkerId !== heartbeatingWorkerId) continue;
916
916
  if (!pending.armTimer || Date.now() >= pending.hardDeadline) continue;
917
+ liveInFlight = true;
917
918
  clearTimeout(pending.timer);
918
919
  pending.timer = pending.armTimer();
919
920
  }
921
+ // A beat proves liveness only while the worker is on a command we still wait for.
922
+ // 7.9.26: a worker wedged inside one command (its handleCommand never settled) kept
923
+ // beating every 5s for hours after that command's hard deadline had expired; each
924
+ // beat refreshed the stale clock, so "HTTP poll timeout" never fired, no successor
925
+ // could take the lease, and every tool call died with "Extension timeout". Once
926
+ // nothing dispatched to this worker is in flight, its beats no longer count as polls.
927
+ if (liveInFlight) _extensionLastPollTime = Date.now();
920
928
  res.writeHead(204);
921
929
  res.end();
922
930
  return;
@@ -1219,6 +1227,18 @@ _staleHttpTimer.unref(); // stale-detection must not keep the Node process aliv
1219
1227
 
1220
1228
  // ========== SHARED EXTENSION LOGIC ==========
1221
1229
 
1230
+ // A worker that keeps beating but never finishes a command holds the profile lease
1231
+ // hostage. Releasing it is the same bookkeeping the stale timer does on poll timeout.
1232
+ function _dropWedgedHttpWorker(reason) {
1233
+ if (!_extensionConnected && !_activeHttpWorkerId) return;
1234
+ _extensionConnected = false;
1235
+ _activeHttpWorkerId = "";
1236
+ _connectingHttpWorkers.clear();
1237
+ _reloadHttpWorkerHandoff = null;
1238
+ _drainOnDisconnect(`wedged worker: ${reason}`);
1239
+ console.error(`[Safari MCP] Extension worker dropped as wedged (${reason})`);
1240
+ }
1241
+
1222
1242
  // Drain pending requests and command queue on disconnect — allows fast fallback to AppleScript
1223
1243
  function _drainOnDisconnect(reason) {
1224
1244
  if (process.env.SAFARI_PROFILE) {
@@ -1301,6 +1321,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1301
1321
  // hardDeadline is the ceiling, so a worker that beats forever cannot hang us.
1302
1322
  const hardDeadline = Date.now() + Math.max(timeoutMs * 4, 180000);
1303
1323
  const expire = () => {
1324
+ const pending = _pendingRequests.get(id);
1304
1325
  _pendingRequests.delete(id);
1305
1326
  if (reloadHandoff) _cancelReloadHttpWorkerHandoff(reloadHandoff);
1306
1327
  // Also drop it from the HTTP poll queue — otherwise the extension could poll this command
@@ -1308,6 +1329,15 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1308
1329
  const qi = _commandQueue.findIndex(c => c.id === id);
1309
1330
  if (qi >= 0) _commandQueue.splice(qi, 1);
1310
1331
  reject(new Error(`Extension timeout after ${timeoutMs}ms`));
1332
+ // The worker took this command and never answered, even past the hard ceiling: it is
1333
+ // wedged (see /heartbeat). Drop its lease now instead of waiting for the stale timer,
1334
+ // so a fresh worker — or Safari re-spawning this one — can connect at once.
1335
+ if (
1336
+ pending && pending.dispatchedWorkerId && pending.dispatchedWorkerId === _activeHttpWorkerId &&
1337
+ Date.now() >= pending.hardDeadline
1338
+ ) {
1339
+ _dropWedgedHttpWorker(`${type} exceeded its hard deadline`);
1340
+ }
1311
1341
  };
1312
1342
  const armTimer = () => setTimeout(expire, Math.min(timeoutMs, Math.max(0, hardDeadline - Date.now())));
1313
1343
  _pendingRequests.set(id, {
@@ -1415,6 +1445,7 @@ const _noOwnershipCheck = new Set([
1415
1445
  "reload_extension",
1416
1446
  // Read-only — don't modify the page
1417
1447
  "read_page", "get_source", "snapshot", "accessibility_snapshot",
1448
+ "list_frames",
1418
1449
  "get_element", "query_all", "screenshot", "screenshot_element",
1419
1450
  "get_console", "list_console_messages", "start_console",
1420
1451
  "get_network", "list_network_requests", "start_network_capture",
@@ -2876,6 +2907,23 @@ server.tool(
2876
2907
  }
2877
2908
  );
2878
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
+
2879
2927
  // ========== EVALUATE JAVASCRIPT ==========
2880
2928
 
2881
2929
  server.tool(
@@ -2883,12 +2931,19 @@ server.tool(
2883
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.",
2884
2932
  {
2885
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."),
2886
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"),
2887
2936
  },
2888
2937
  async (args) => {
2889
2938
  const result = await extensionOrFallback(
2890
- "evaluate", { script: args.script, ..._explicitReceipt(args) },
2891
- () => 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
+ }
2892
2947
  );
2893
2948
  return { content: [{ type: "text", text: (typeof result === 'string' ? result : JSON.stringify(result)) || "(no return value)" }] };
2894
2949
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.19.0",
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",