safari-mcp 2.3.0 → 2.4.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.
@@ -242,6 +242,7 @@ async function handleCommand(type, payload) {
242
242
  await browser.tabs.goBack(tabId);
243
243
  await waitForTabSettled(tabId, 3000);
244
244
  const updated = await browser.tabs.get(tabId);
245
+ _setSessionTab(sessionId, updated.id, updated.url);
245
246
  return { title: updated.title, url: updated.url };
246
247
  }
247
248
 
@@ -249,6 +250,7 @@ async function handleCommand(type, payload) {
249
250
  await browser.tabs.goForward(tabId);
250
251
  await waitForTabSettled(tabId, 3000);
251
252
  const updated = await browser.tabs.get(tabId);
253
+ _setSessionTab(sessionId, updated.id, updated.url);
252
254
  return { title: updated.title, url: updated.url };
253
255
  }
254
256
 
@@ -524,7 +526,7 @@ async function handleCommand(type, payload) {
524
526
 
525
527
  // --- Visibility check ---
526
528
  const cs = window.getComputedStyle(el);
527
- if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0") {
529
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.visibility === "collapse" || parseFloat(cs.opacity) === 0) {
528
530
  return "Element not visible (display/visibility/opacity)";
529
531
  }
530
532
 
@@ -1647,16 +1649,20 @@ async function _verifyProfileMatch(expectedProfile) {
1647
1649
  await new Promise(r => setTimeout(r, 500));
1648
1650
 
1649
1651
  // Ask the server to verify which profile has this nonce
1650
- const verifyRes = await fetch(`${HTTP_URL}/verify-profile`, {
1651
- method: "POST",
1652
- headers: { "Content-Type": "application/json" },
1653
- body: JSON.stringify({ nonce, expectedProfile }),
1654
- signal: AbortSignal.timeout(5000),
1655
- });
1656
- // Clean up test tab
1657
- await browser.tabs.remove(checkTab.id).catch(() => {});
1652
+ let verifyRes;
1653
+ try {
1654
+ verifyRes = await fetch(`${HTTP_URL}/verify-profile`, {
1655
+ method: "POST",
1656
+ headers: { "Content-Type": "application/json" },
1657
+ body: JSON.stringify({ nonce, expectedProfile }),
1658
+ signal: AbortSignal.timeout(5000),
1659
+ });
1660
+ } finally {
1661
+ // Always clean up test tab — even if fetch fails
1662
+ await browser.tabs.remove(checkTab.id).catch(() => {});
1663
+ }
1658
1664
 
1659
- if (verifyRes.ok) {
1665
+ if (verifyRes && verifyRes.ok) {
1660
1666
  const result = await verifyRes.json();
1661
1667
  if (result.match) {
1662
1668
  await browser.storage.local.set({ mcpVerifiedProfile: expectedProfile });
package/index.js CHANGED
@@ -17,6 +17,8 @@ import { readFileSync } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
 
20
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB cap on POST body — prevents DoS
21
+
20
22
  // ========== SINGLETON: kill stale instances from previous sessions ==========
21
23
  // NOTE: Claude Code VSCode may start 2 instances simultaneously (~40ms apart).
22
24
  // Only kill instances running >10 seconds (truly stale), not fresh siblings.
@@ -94,7 +96,7 @@ function _startMemoryMonitor() {
94
96
  // Use pgrep + ps directly — avoids spawning bash, grep, awk pipeline
95
97
  let psOut = "0";
96
98
  try {
97
- const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8" }).trim();
99
+ const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
98
100
  if (pids) {
99
101
  const pidList = pids.split("\n").join(",");
100
102
  psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
@@ -111,8 +113,17 @@ function _startMemoryMonitor() {
111
113
  }
112
114
  if (oldestIdx !== null) {
113
115
  try {
114
- safari.setActiveTabIndex(oldestIdx);
115
- await safari.closeTab();
116
+ // Re-resolve by URL before closing (indices shift as tabs change)
117
+ const info = _openedTabs.get(oldestIdx);
118
+ if (info?.url) {
119
+ const tabs = await safari.listTabs();
120
+ const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
121
+ const match = parsed.find(t => t.url === info.url);
122
+ if (match) {
123
+ safari.setActiveTabIndex(match.index);
124
+ await safari.closeTab();
125
+ }
126
+ }
116
127
  } catch {}
117
128
  _untrackTab(oldestIdx);
118
129
  }
@@ -123,8 +134,11 @@ function _startMemoryMonitor() {
123
134
  }
124
135
 
125
136
  // Cleanup on exit
137
+ let _cleaningUp = false;
126
138
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
127
139
  process.on(sig, async () => {
140
+ if (_cleaningUp) return; // Prevent double-exit on rapid signal repeat
141
+ _cleaningUp = true;
128
142
  await _cleanupTabs();
129
143
  process.exit(0);
130
144
  });
@@ -250,7 +264,7 @@ try {
250
264
  // POST /result — extension sends command result
251
265
  if (req.method === "POST" && req.url === "/result") {
252
266
  let body = "";
253
- req.on("data", (chunk) => (body += chunk));
267
+ req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
254
268
  req.on("end", () => {
255
269
  try {
256
270
  const msg = JSON.parse(body);
@@ -291,7 +305,7 @@ try {
291
305
  // POST /verify-profile — extension asks server to check which profile has a nonce tab
292
306
  if (req.method === "POST" && req.url === "/verify-profile") {
293
307
  let body = "";
294
- req.on("data", (chunk) => (body += chunk));
308
+ req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
295
309
  req.on("end", async () => {
296
310
  try {
297
311
  const { nonce, expectedProfile } = JSON.parse(body);
@@ -299,9 +313,9 @@ try {
299
313
  const safeNonce = String(nonce).replace(/[^0-9]/g, ''); // nonce is numeric only
300
314
  const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, ''); // whitelist: letters, numbers, spaces, hyphens, underscores
301
315
  // Check via AppleScript — look for the nonce in the profile window
302
- const { execFile } = await import("node:child_process");
303
- const { promisify } = await import("node:util");
304
- const execFileAsync = promisify(execFile);
316
+ const { execFile: execFileCb } = await import("node:child_process");
317
+ const { promisify: pfy } = await import("node:util");
318
+ const execFileAsync = pfy(execFileCb);
305
319
  const script = `tell application "Safari"
306
320
  repeat with w in every window
307
321
  repeat with t in every tab of w
@@ -343,7 +357,7 @@ try {
343
357
  // POST /proxy-command — secondary instances send commands through primary
344
358
  if (req.method === "POST" && req.url === "/proxy-command") {
345
359
  let body = "";
346
- req.on("data", (chunk) => (body += chunk));
360
+ req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
347
361
  req.on("end", async () => {
348
362
  try {
349
363
  const { type, payload } = JSON.parse(body);
@@ -391,6 +405,7 @@ setTimeout(() => {
391
405
  }, 2000);
392
406
 
393
407
  async function _checkPrimaryExtension() {
408
+ if (_isExtensionHost) return; // Already hosting — stop polling
394
409
  try {
395
410
  const res = await fetch(`http://127.0.0.1:${HTTP_PORT}/proxy-check`, {
396
411
  method: "GET",
@@ -1126,12 +1141,16 @@ server.tool(
1126
1141
  if (resolved && resolved.url !== 'about:blank') {
1127
1142
  if (urlContains && !resolved.url.includes(urlContains)) continue;
1128
1143
  await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
1144
+ safari.setActiveTabIndex(resolved.index);
1145
+ safari.setActiveTabURL(resolved.url);
1129
1146
  return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
1130
1147
  }
1131
1148
  continue;
1132
1149
  }
1133
1150
  if (urlContains && !tab.url.includes(urlContains)) continue;
1134
1151
  await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1152
+ safari.setActiveTabIndex(tab.index);
1153
+ safari.setActiveTabURL(tab.url);
1135
1154
  return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1136
1155
  }
1137
1156
  }
package/mcp-helpers.js CHANGED
@@ -75,6 +75,8 @@ if (window.__mcpVersion !== 5) {
75
75
  if (!window.mcpIsVisible(el)) return false;
76
76
  if (el.disabled || el.getAttribute('aria-disabled') === 'true') return false;
77
77
  if (el.matches && el.matches('input[type="hidden"]')) return false;
78
+ var cs = window.getComputedStyle(el);
79
+ if (cs && cs.pointerEvents === 'none') return false;
78
80
  return true;
79
81
  };
80
82
  window.mcpPickActionable = function(el) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { execFile, spawn } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { tmpdir } from "node:os";
9
- import { join, dirname } from "node:path";
9
+ import { join, dirname, resolve as resolvePath } from "node:path";
10
10
  import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
11
11
  import { readFileSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
@@ -84,11 +84,9 @@ function cleanupHelper() {
84
84
  _helperProc = null;
85
85
  }
86
86
  }
87
+ // Signal handlers (SIGINT/SIGTERM/SIGHUP) are registered in index.js only.
88
+ // cleanupHelper runs via process.on("exit"), which fires when index.js calls process.exit().
87
89
  process.on("exit", cleanupHelper);
88
- process.on("SIGINT", () => { cleanupHelper(); process.exit(0); });
89
- process.on("SIGTERM", () => { cleanupHelper(); process.exit(0); });
90
- // SIGHUP = terminal closed, uncaught = crash — still clean up
91
- process.on("SIGHUP", () => { cleanupHelper(); process.exit(0); });
92
90
  process.on("uncaughtException", (err) => { console.error("Uncaught:", err); cleanupHelper(); process.exit(1); });
93
91
 
94
92
  // ========== ACTIVE TAB TRACKING ==========
@@ -388,7 +386,7 @@ function _osascriptFastHelper(script, timeout) {
388
386
  function _helperNativeClick(x, y, doubleClick = false, windowId = 0, timeout = 5000) {
389
387
  return new Promise((resolve, reject) => {
390
388
  if (!_helperProc) startHelper();
391
- if (!_helperProc || !_helperProc.stdin) {
389
+ if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
392
390
  reject(new Error("safari-helper not available for native click"));
393
391
  return;
394
392
  }
@@ -698,301 +696,8 @@ export async function getPageSource({ maxLength = 200000 } = {}) {
698
696
  // Loaded from mcp-helpers.js at startup — enables syntax highlighting, linting, and easier maintenance
699
697
  const INJECT_MCP_HELPERS = readFileSync(join(__dirname, 'mcp-helpers.js'), 'utf8');
700
698
 
701
- // NOTE: Helper code previously inlined here (~300 lines) is now in mcp-helpers.js
702
- /* eslint-disable */
703
- const _LEGACY_INLINE_START = `if(window.__mcpVersion!==5){window.__mcpVersion=5;
704
- window.__mcpRefs=window.__mcpRefs||{};
705
- window.__mcpCachedRoots=null;
706
- window.__mcpRootsDirty=true;
707
- if(!window.__mcpRootsObserver){
708
- window.__mcpRootsObserver=new MutationObserver(function(){window.__mcpRootsDirty=true;});
709
- window.__mcpRootsObserver.observe(document.documentElement,{childList:true,subtree:true});
710
- }
711
- window.mcpCollectRoots=function(){
712
- if(!window.__mcpRootsDirty&&window.__mcpCachedRoots)return window.__mcpCachedRoots;
713
- var roots=[];
714
- function collect(root){
715
- roots.push(root);
716
- var all=root.querySelectorAll('*');
717
- for(var i=0;i<all.length;i++){
718
- if(all[i].shadowRoot)collect(all[i].shadowRoot);
719
- }
720
- }
721
- collect(document);
722
- var iframes=document.querySelectorAll('iframe');
723
- for(var i=0;i<iframes.length;i++){
724
- try{var doc=iframes[i].contentDocument;if(doc)collect(doc);}catch(e){}
725
- }
726
- window.__mcpCachedRoots=roots;
727
- window.__mcpRootsDirty=false;
728
- return roots;
729
- };
730
- window.mcpQuerySelectorDeep=function(selector){
731
- try{
732
- var direct=document.querySelector(selector);
733
- if(direct)return direct;
734
- }catch(e){return null}
735
- var roots=window.mcpCollectRoots();
736
- for(var i=1;i<roots.length;i++){
737
- try{
738
- var found=roots[i].querySelector(selector);
739
- if(found)return found;
740
- }catch(e){}
741
- }
742
- var iframes=document.querySelectorAll('iframe');
743
- for(var i=0;i<iframes.length;i++){
744
- try{
745
- var doc=iframes[i].contentDocument;
746
- if(doc){var found=doc.querySelector(selector);if(found)return found;}
747
- }catch(e){}
748
- }
749
- return null;
750
- };
751
- window.mcpElementFromPoint=function(x,y){
752
- var el=document.elementFromPoint(x,y);
753
- while(el&&el.shadowRoot){
754
- try{
755
- if(typeof el.shadowRoot.elementFromPoint!=='function')break;
756
- var inner=el.shadowRoot.elementFromPoint(x,y);
757
- if(!inner||inner===el)break;
758
- el=inner;
759
- }catch(e){break}
760
- }
761
- return el;
762
- };
763
- window.mcpIsVisible=function(el){
764
- if(!el||el.nodeType!==1||!el.isConnected)return false;
765
- var cs=window.getComputedStyle(el);
766
- if(!cs||cs.display==='none'||cs.visibility==='hidden'||cs.visibility==='collapse'||cs.pointerEvents==='none'||cs.opacity==='0')return false;
767
- var r=el.getBoundingClientRect();
768
- return r.width>0&&r.height>0;
769
- };
770
- window.mcpIsActionable=function(el){
771
- if(!window.mcpIsVisible(el))return false;
772
- if(el.disabled||el.getAttribute('aria-disabled')==='true')return false;
773
- if(el.matches&&el.matches('input[type="hidden"]'))return false;
774
- return true;
775
- };
776
- window.mcpPickActionable=function(el){
777
- var node=el&&el.nodeType===1?el:(el&&el.parentElement)||null;
778
- while(node){
779
- if(window.mcpIsActionable(node)&&node.matches&&node.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,option,[role],[onclick],[tabindex],[contenteditable=""],[contenteditable="true"]'))return node;
780
- node=node.parentElement;
781
- }
782
- return el&&el.nodeType===1?el:null;
783
- };
784
- window.mcpFindByAttr=function(attr,value,selector){
785
- if(!value)return null;
786
- var roots=window.mcpCollectRoots();
787
- var sel=selector||('['+attr+']');
788
- for(var i=0;i<roots.length;i++){
789
- var els=roots[i].querySelectorAll(sel);
790
- for(var j=0;j<els.length;j++){
791
- var current=attr==='href'?els[j].href:els[j].getAttribute(attr);
792
- if(current===value&&window.mcpIsVisible(els[j]))return els[j];
793
- }
794
- }
795
- return null;
796
- };
797
- window.mcpNormalizeText=function(value){
798
- return String(value||'').replace(/\\s+/g,' ').trim();
799
- };
800
- window.mcpResolveTarget=function(el){
801
- var base=window.mcpPickActionable(el)||el;
802
- if(!base)return null;
803
- try{base.scrollIntoView({block:'center',inline:'center'});}catch(e){}
804
- var r=base.getBoundingClientRect();
805
- var dx=Math.min(12,Math.max(4,r.width/4));
806
- var dy=Math.min(12,Math.max(4,r.height/4));
807
- var pts=[
808
- [r.left+r.width/2,r.top+r.height/2],
809
- [r.left+dx,r.top+r.height/2],
810
- [r.right-dx,r.top+r.height/2],
811
- [r.left+r.width/2,r.top+dy],
812
- [r.left+r.width/2,r.bottom-dy]
813
- ];
814
- for(var i=0;i<pts.length;i++){
815
- var x=pts[i][0],y=pts[i][1];
816
- if(x<0||y<0||x>window.innerWidth||y>window.innerHeight)continue;
817
- var hit=window.mcpElementFromPoint(x,y);
818
- if(!hit)continue;
819
- if(base===hit||base.contains(hit)||hit.contains(base))return window.mcpPickActionable(hit)||hit;
820
- // Overlay pattern: transparent button/link overlays a text label (e.g. dropdown items).
821
- // If the hit is an interactive element sharing a nearby common ancestor, prefer it.
822
- var hitAction=window.mcpPickActionable(hit);
823
- if(hitAction&&hitAction!==base&&hitAction.matches&&hitAction.matches('button,a[href],[role="button"],[role="option"],[role="menuitem"]')){
824
- var p=base.parentElement;
825
- for(var k=0;k<4&&p;k++){
826
- if(p.contains(hitAction))return hitAction;
827
- p=p.parentElement;
828
- }
829
- }
830
- }
831
- return base;
832
- };
833
- window.mcpClick=function(el){
834
- var target=window.mcpResolveTarget(el);
835
- if(!window.mcpIsActionable(target))return false;
836
- var r=target.getBoundingClientRect();
837
- var x=r.left+r.width/2,y=r.top+r.height/2;
838
- var beforeUrl=location.href;
839
- var anchor=target.closest?target.closest('a[href]'):null;
840
- var href=anchor&&anchor.href&&!anchor.href.startsWith('javascript:')?anchor.href:'';
841
- var s={bubbles:true,cancelable:true,composed:true,view:window,clientX:x,clientY:y,button:0,detail:1};
842
- var p={...s,pointerId:1,pointerType:'mouse',isPrimary:true,width:1,height:1,pressure:0.5};
843
- target.dispatchEvent(new PointerEvent('pointerover',{...p,buttons:0}));
844
- target.dispatchEvent(new MouseEvent('mouseover',{...s,buttons:0}));
845
- target.dispatchEvent(new PointerEvent('pointerenter',{...p,buttons:0}));
846
- target.dispatchEvent(new MouseEvent('mouseenter',{...s,buttons:0}));
847
- target.dispatchEvent(new PointerEvent('pointermove',{...p,buttons:0}));
848
- target.dispatchEvent(new MouseEvent('mousemove',{...s,buttons:0}));
849
- target.dispatchEvent(new PointerEvent('pointerdown',{...p,buttons:1}));
850
- target.dispatchEvent(new MouseEvent('mousedown',{...s,buttons:1}));
851
- if(target.focus){try{target.focus({preventScroll:true});}catch(e){try{target.focus();}catch(_){}}}
852
- target.dispatchEvent(new PointerEvent('pointerup',{...p,buttons:0,pressure:0}));
853
- target.dispatchEvent(new MouseEvent('mouseup',{...s,buttons:0}));
854
- try{
855
- if(typeof target.click==='function'){
856
- target.click();
857
- if(href&&href!==beforeUrl){
858
- location.href=href;
859
- }
860
- }
861
- }catch(e){}
862
- target.dispatchEvent(new MouseEvent('click',{...s,buttons:0}));
863
- if(href&&href!==beforeUrl){
864
- location.href=href;
865
- }
866
- var form=target.closest?target.closest('form'):null;
867
- if(form&&(target.type==='submit'||(target.tagName==='BUTTON'&&target.type!=='button'&&target.type!=='reset'))){
868
- try{form.requestSubmit?form.requestSubmit(target.type==='submit'?target:undefined):form.submit();}catch(e){}
869
- }
870
- return true;
871
- };
872
- window.mcpReactClick=function(el){
873
- var startNode=window.mcpResolveTarget(el)||el;
874
- if(!startNode)return false;
875
- function makeSynth(targetNode, type){
876
- var r=targetNode.getBoundingClientRect();
877
- return{type:type||'click',target:targetNode,currentTarget:targetNode,
878
- clientX:r.left+r.width/2,clientY:r.top+r.height/2,pageX:r.left+r.width/2+window.scrollX,pageY:r.top+r.height/2+window.scrollY,
879
- preventDefault:function(){},stopPropagation:function(){},nativeEvent:new MouseEvent(type||'click'),persist:function(){},bubbles:true,cancelable:true};
880
- }
881
- // For React radio/checkbox inputs, onChange is the primary handler (not onClick)
882
- var isToggle=startNode.tagName==='INPUT'&&(startNode.type==='radio'||startNode.type==='checkbox');
883
- function tryOnChange(propsObj,targetNode){
884
- if(!isToggle||!propsObj.onChange)return false;
885
- var newChecked=startNode.type==='radio'?true:!startNode.checked;
886
- propsObj.onChange({target:{value:startNode.value,checked:newChecked,type:startNode.type,name:startNode.name,id:startNode.id,tagName:'INPUT'},
887
- currentTarget:targetNode,preventDefault:function(){},stopPropagation:function(){},nativeEvent:new Event('change'),persist:function(){},bubbles:true,cancelable:true,type:'change'});
888
- return true;
889
- }
890
- var node=startNode;
891
- for(var depth=0;depth<15&&node;depth++){
892
- var pk=Object.keys(node).find(function(k){return k.startsWith('__reactProps$')});
893
- if(pk&&node[pk]){
894
- var props=node[pk];
895
- if(tryOnChange(props,node))return true;
896
- if(props.onClick){props.onClick(makeSynth(node,'click'));return true}
897
- if(props.onMouseDown){props.onMouseDown(makeSynth(node,'mousedown'));return true}
898
- if(props.onPointerDown){props.onPointerDown(makeSynth(node,'pointerdown'));return true}
899
- }
900
- var fk=Object.keys(node).find(function(k){return k.startsWith('__reactFiber$')||k.startsWith('__reactInternalInstance$')});
901
- if(fk){
902
- var f=node[fk];
903
- while(f){
904
- if(f.memoizedProps){
905
- if(tryOnChange(f.memoizedProps,node))return true;
906
- if(f.memoizedProps.onClick){f.memoizedProps.onClick(makeSynth(node,'click'));return true}
907
- if(f.memoizedProps.onMouseDown){f.memoizedProps.onMouseDown(makeSynth(node,'mousedown'));return true}
908
- if(f.memoizedProps.onPointerDown){f.memoizedProps.onPointerDown(makeSynth(node,'pointerdown'));return true}
909
- }
910
- f=f.return;
911
- }
912
- }
913
- node=node.parentElement;
914
- }
915
- return false;
916
- };
917
- window.mcpClickWithReact=function(el){
918
- var target=window.mcpResolveTarget(el)||el;
919
- var reactFired=false;
920
- try{reactFired=window.mcpReactClick(target);}catch(e){}
921
- var anchor=target&&target.closest?target.closest('a[href]'):null;
922
- if(!reactFired||anchor)window.mcpClick(target);
923
- return target;
924
- };
925
- window.mcpFindRef=function(ref){
926
- var el=window.mcpQuerySelectorDeep('[data-mcp-ref="'+ref+'"]');
927
- if(el)return el;
928
- if(!window.__mcpRefs||!window.__mcpRefs[ref])return null;
929
- var m=window.__mcpRefs[ref];
930
- if(m.id){el=window.mcpFindByAttr('id',m.id);if(el)return el}
931
- if(m.testid){el=window.mcpFindByAttr('data-testid',m.testid);if(el)return el}
932
- if(m.nameAttr){el=window.mcpFindByAttr('name',m.nameAttr);if(el)return el}
933
- if(m.href){el=window.mcpFindByAttr('href',m.href,'a[href]');if(el)return el}
934
- if(m.al){el=window.mcpFindByAttr('aria-label',m.al);if(el)return el}
935
- if(m.ph){el=window.mcpFindByAttr('placeholder',m.ph);if(el)return el}
936
- if(m.text){
937
- el=window.mcpFindText(m.text,true)||window.mcpFindText(m.text,false);
938
- if(el)return el;
939
- }
940
- if(m.cx!==undefined&&m.cy!==undefined){
941
- try{
942
- window.scrollTo(window.scrollX,Math.max(0,m.cy-window.innerHeight/2));
943
- }catch(e){}
944
- el=window.mcpElementFromPoint(m.cx-window.scrollX,m.cy-window.scrollY);
945
- if(el)return window.mcpPickActionable(el)||el;
946
- }
947
- return null;
948
- };
949
- window.mcpFindText=function(text,exact){
950
- var needle=window.mcpNormalizeText(text);
951
- var best=null,bestScore=Infinity;
952
- function consider(node){
953
- var target=window.mcpPickActionable(node)||node;
954
- if(!window.mcpIsVisible(target))return;
955
- var r=target.getBoundingClientRect();
956
- var area=r.width*r.height;
957
- var interactive=target.matches&&target.matches('a[href],button,input:not([type="hidden"]),textarea,select,summary,label,[role=button],[role=link],[role=tab],[onclick],[tabindex]');
958
- var score=area+(interactive?0:1000000);
959
- if(score<bestScore){best=target;bestScore=score}
960
- }
961
- var roots=window.mcpCollectRoots();
962
- for(var i=0;i<roots.length;i++){
963
- var attrEls=roots[i].querySelectorAll('[aria-label],[placeholder],[title],[data-testid],[alt]');
964
- for(var j=0;j<attrEls.length;j++){
965
- var a=attrEls[j];
966
- var vals=[a.getAttribute('aria-label'),a.getAttribute('placeholder'),a.getAttribute('title'),a.getAttribute('data-testid'),a.getAttribute('alt')];
967
- for(var k=0;k<vals.length;k++){
968
- var val=vals[k];
969
- if(!val)continue;
970
- var normalized=window.mcpNormalizeText(val);
971
- if(exact?normalized===needle:normalized.includes(needle)){consider(a);break}
972
- }
973
- }
974
- var tw=document.createTreeWalker(roots[i],NodeFilter.SHOW_TEXT,null);
975
- while(tw.nextNode()){
976
- var n=tw.currentNode;
977
- var t=window.mcpNormalizeText(n.textContent);
978
- if(!t)continue;
979
- if(exact?(t!==needle):!t.includes(needle))continue;
980
- if(n.parentElement)consider(n.parentElement);
981
- }
982
- }
983
- if(!best){
984
- var allEls=document.querySelectorAll('*');
985
- for(var i=0;i<allEls.length;i++){
986
- var el=allEls[i];
987
- var it=window.mcpNormalizeText(el.innerText);
988
- if(!it)continue;
989
- if(exact?(it!==needle):!it.includes(needle))continue;
990
- consider(el);
991
- }
992
- }
993
- return best;
994
- };
995
- }`;
699
+ // NOTE: ~300 lines of legacy inline helpers were here — now in mcp-helpers.js (deleted in v2.4.0)
700
+
996
701
 
997
702
  // Precomputed escaped helper string — avoids re-escaping ~4KB on every injection call
998
703
  const _HELPERS_ESCAPED = INJECT_MCP_HELPERS
@@ -1221,6 +926,7 @@ export async function nativeClick({ selector, text, x, y, ref, doubleClick = fal
1221
926
 
1222
927
  export async function fill({ selector, value, ref }) {
1223
928
  if (ref) selector = refSelector(ref);
929
+ if (!selector) throw new Error("fill requires selector or ref");
1224
930
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
1225
931
  // Proper escaping order: backslashes first, then quotes
1226
932
  const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
@@ -1680,7 +1386,7 @@ export async function screenshot({ fullPage = false } = {}) {
1680
1386
  }
1681
1387
  // Compress: convert PNG to JPEG (50% quality) + resize to max 1200px width
1682
1388
  // Cuts ~600KB PNG → ~60KB JPEG — critical for staying under 20MB context limit
1683
- const jpgFile = tmpFile.replace('.png', '.jpg');
1389
+ const jpgFile = tmpFile.replace(/\.png$/, '.jpg');
1684
1390
  try {
1685
1391
  await execFileAsync("sips", [
1686
1392
  "-s", "format", "jpeg",
@@ -2170,8 +1876,7 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
2170
1876
  // ========== FILE PATH SAFETY ==========
2171
1877
  // Prevent reading sensitive system files via upload/paste tools
2172
1878
  function _validateFilePath(filePath) {
2173
- const { resolve } = require("node:path");
2174
- const resolved = resolve(filePath);
1879
+ const resolved = resolvePath(filePath);
2175
1880
  if (resolved.includes('..')) throw new Error("Path traversal not allowed: " + filePath);
2176
1881
  const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
2177
1882
  const lower = resolved.toLowerCase();
@@ -2198,10 +1903,10 @@ export async function uploadFile({ selector, filePath }) {
2198
1903
  repeat with w in every window
2199
1904
  if exists sheet 1 of w then
2200
1905
  try
2201
- click button "ביטול" of sheet 1 of w
1906
+ click button "Cancel" of sheet 1 of w
2202
1907
  on error
2203
1908
  try
2204
- click button "Cancel" of sheet 1 of w
1909
+ click button "ביטול" of sheet 1 of w
2205
1910
  on error
2206
1911
  key code 53
2207
1912
  end try
@@ -2659,6 +2364,7 @@ export async function takeSnapshot({ selector, _gen } = {}) {
2659
2364
  }
2660
2365
 
2661
2366
  window.__mcpRefs = {};
2367
+ window.__mcpRefsTime = Date.now();
2662
2368
  var root = ${root};
2663
2369
  if (!root) return 'Element not found';
2664
2370
  walk(root, 0);
@@ -3435,6 +3141,8 @@ export async function scrollToElement({ selector, text, block = "center", timeou
3435
3141
  // Navigate + wait + read — the most common 3-step workflow
3436
3142
  export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
3437
3143
  await refreshTargetWindow();
3144
+ // Suppress onbeforeunload dialogs (same as navigate())
3145
+ await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
3438
3146
  let targetUrl = url;
3439
3147
  if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
3440
3148
  const safeUrl = targetUrl.replace(/"/g, '\\"');