safari-mcp 2.15.12 → 2.16.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.
@@ -1755,7 +1755,7 @@ async function handleCommand(type, payload) {
1755
1755
 
1756
1756
  // --- Query All ---
1757
1757
  case "query_all": {
1758
- return await execInTab((selector, limit) => {
1758
+ const queryFn = (selector, limit) => {
1759
1759
  const els = (window.__mcpDeepQueryAll || document.querySelectorAll.bind(document))(selector, limit);
1760
1760
  const results = [];
1761
1761
  for (let i = 0; i < Math.min(els.length, limit); i++) {
@@ -1766,10 +1766,27 @@ async function handleCommand(type, payload) {
1766
1766
  text: (el.innerText || "").substring(0, 100),
1767
1767
  href: el.href || "", value: el.value || "",
1768
1768
  visible: r.width > 0 && r.height > 0,
1769
+ // Center point relative to the element's OWN frame viewport. In the main
1770
+ // frame that equals page-viewport coordinates; in a cross-origin iframe it
1771
+ // is iframe-relative — check `frame` and offset by the iframe's position
1772
+ // before clicking by coordinates.
1773
+ x: Math.round(r.left + r.width / 2),
1774
+ y: Math.round(r.top + r.height / 2),
1775
+ frame: location.href.substring(0, 120),
1769
1776
  });
1770
1777
  }
1771
1778
  return JSON.stringify(results);
1772
- }, [payload.selector, payload.limit || 20], tabId);
1779
+ };
1780
+
1781
+ const mainResult = await execInTab(queryFn, [payload.selector, payload.limit || 20], tabId);
1782
+ // The main frame is authoritative when it matches. Cross-origin iframes (GHL's
1783
+ // workflow list, embedded editors) are invisible to it, so fall back the same
1784
+ // way click/fill already do rather than reporting "no matches".
1785
+ try {
1786
+ if (mainResult && JSON.parse(mainResult).length > 0) return mainResult;
1787
+ } catch { return mainResult; }
1788
+ const frameResult = await execAcrossFrames(queryFn, [payload.selector, payload.limit || 20], tabId);
1789
+ return frameResult || mainResult;
1773
1790
  }
1774
1791
 
1775
1792
  default:
@@ -2149,6 +2166,33 @@ async function execInAllFrames(func, args = [], tabId = null) {
2149
2166
  }
2150
2167
  }
2151
2168
 
2169
+ // Like execInAllFrames, but for functions that return a JSON array: it skips frames
2170
+ // that matched nothing instead of stopping at the first non-null result. The main
2171
+ // frame nearly always returns "[]" — non-null — which would otherwise mask every
2172
+ // cross-origin frame behind it.
2173
+ async function execAcrossFrames(func, args = [], tabId = null) {
2174
+ const id = tabId || (await getActiveTab()).id;
2175
+ try {
2176
+ const results = await browser.scripting.executeScript({
2177
+ target: { tabId: id, allFrames: true },
2178
+ world: "MAIN",
2179
+ func,
2180
+ args,
2181
+ });
2182
+ const merged = [];
2183
+ for (const r of results) {
2184
+ if (!r || r.result == null) continue;
2185
+ try {
2186
+ const parsed = JSON.parse(r.result);
2187
+ if (Array.isArray(parsed) && parsed.length) merged.push(...parsed);
2188
+ } catch { /* frame returned a non-array payload — ignore it */ }
2189
+ }
2190
+ return merged.length ? JSON.stringify(merged) : null;
2191
+ } catch {
2192
+ return null;
2193
+ }
2194
+ }
2195
+
2152
2196
  async function waitForTabLoad(tabId, timeout = 30000) {
2153
2197
  // Check if already complete BEFORE registering listeners (prevents missing instant-complete events)
2154
2198
  try {
@@ -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.2",
5
+ "version": "2.10.3",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.15.12",
3
+ "version": "2.16.0",
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
@@ -3171,25 +3171,15 @@ export async function replaceEditorContent({ text }) {
3171
3171
 
3172
3172
  export async function screenshot({ fullPage = false } = {}) {
3173
3173
  await refreshTargetWindow();
3174
+ return _withTargetTabFronted(() => _screenshotFronted({ fullPage }));
3175
+ }
3176
+
3177
+ async function _screenshotFronted({ fullPage }) {
3174
3178
  const tmpFile = join(tmpdir(), `safari-screenshot-${Date.now()}.png`);
3175
3179
  try {
3176
- // Check if target tab is a background tab — if so, use JS screenshot to avoid tab jumping
3177
- let isBackgroundTab = false;
3178
- if (_st().activeTabIndex) {
3179
- try {
3180
- const currentIdx = await osascriptFast(
3181
- `tell application "Safari" to return index of current tab of ${getTargetWindowRef()}`
3182
- );
3183
- isBackgroundTab = Number(currentIdx) !== _st().activeTabIndex;
3184
- } catch (_) {}
3185
- }
3186
- // When on a background tab, go straight to JS-based screenshot (no tab switch, no focus steal)
3187
- const skipScreencapture = isBackgroundTab;
3188
-
3189
- // Try screencapture — use osascript's do shell script to bypass VS Code permission issue
3190
- const windowIdRaw = !skipScreencapture ? await osascript(
3180
+ const windowIdRaw = await osascript(
3191
3181
  `tell application "Safari" to return id of ${getTargetWindowRef()}`
3192
- ).catch(() => null) : null;
3182
+ ).catch(() => null);
3193
3183
  // Window IDs are OS-assigned integers — reject anything non-numeric before it reaches
3194
3184
  // `do shell script "/usr/sbin/screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
3195
3185
  const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
@@ -3308,6 +3298,11 @@ export async function screenshot({ fullPage = false } = {}) {
3308
3298
  // ========== ELEMENT SCREENSHOT ==========
3309
3299
 
3310
3300
  export async function screenshotElement({ selector }) {
3301
+ await refreshTargetWindow();
3302
+ return _withTargetTabFronted(() => _screenshotElementFronted({ selector }));
3303
+ }
3304
+
3305
+ async function _screenshotElementFronted({ selector }) {
3311
3306
  const sel = escJsSingleQuote(selector);
3312
3307
  // Use html2canvas-like approach: capture element via SVG foreignObject
3313
3308
  const result = await runJS(