safari-mcp 2.7.7 → 2.7.9

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/README.md CHANGED
@@ -426,13 +426,22 @@ The extension requires a one-time build with Xcode (free, included with macOS).
426
426
  # 1. Clone the repo (the npm package does not include the Xcode project)
427
427
  git clone https://github.com/achiya-automation/safari-mcp.git
428
428
  cd safari-mcp
429
+
430
+ # 2. Build the extension
429
431
  xcodebuild -project "xcode/Safari MCP/Safari MCP.xcodeproj" \
430
432
  -scheme "Safari MCP (macOS)" -configuration Release build
431
433
 
432
- # 2. Find and open the built app
433
- open ~/Library/Developer/Xcode/DerivedData/Safari_MCP-*/Build/Products/Release/Safari\ MCP.app
434
+ # 3. Ad-hoc sign the built app so Safari will load it
435
+ # (xcodebuild without a signing identity produces a bundle Safari silently rejects)
436
+ APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Safari_MCP-*/Build/Products/Release -name "Safari MCP.app" -maxdepth 2 | head -1)
437
+ codesign --sign - --force --deep "$APP_PATH"
438
+
439
+ # 4. Open the app (needed once so Safari registers the extension)
440
+ open "$APP_PATH"
434
441
  ```
435
442
 
443
+ Alternatively, open `xcode/Safari MCP/Safari MCP.xcodeproj` directly in Xcode, select your Apple ID under Signing & Capabilities, and click Run. A free personal Apple Developer account is sufficient for local use.
444
+
436
445
  Then in Safari:
437
446
  1. Safari → Settings → Advanced → enable **Show features for web developers**
438
447
  2. Safari → Develop → **Allow Unsigned Extensions** (required each Safari restart)
@@ -456,8 +465,21 @@ Safari MCP needs these one-time permissions:
456
465
  | Permission | Where | Why |
457
466
  |-----------|-------|-----|
458
467
  | JavaScript from Apple Events | Safari → Develop menu | Required for `do JavaScript` |
459
- | Screen Recording | System Settings → Privacy | Required for `safari_screenshot` |
460
- | Accessibility | System Settings → Privacy | Required for `safari_save_pdf` only |
468
+ | Automation → Safari | System Settings → Privacy & Security → Automation | Required for all AppleScript-backed tools |
469
+ | Screen Recording | System Settings → Privacy & Security → Screen Recording | Required for `safari_screenshot` |
470
+ | Accessibility | System Settings → Privacy & Security → Accessibility | Required for `safari_save_pdf` only |
471
+
472
+ ### Granting Automation → Safari (important for IDE users)
473
+
474
+ macOS TCC grants Automation permission to the **parent process** that spawns the MCP server, not to `safari-mcp` itself. So you need to grant **Automation → Safari** to the app that runs Claude Code / Cursor / Windsurf — typically **Visual Studio Code** or **Terminal**.
475
+
476
+ If the permission dialog never appears automatically, run this command once from a Terminal that already has Automation permission:
477
+
478
+ ```bash
479
+ osascript -e 'tell application "Safari" to get URL of current tab of window 1'
480
+ ```
481
+
482
+ That call registers the Terminal app in the Automation database and then triggers the prompt for Safari. After you approve it, subsequent MCP calls from any child process chain will work.
461
483
 
462
484
  ---
463
485
 
@@ -466,6 +488,7 @@ Safari MCP needs these one-time permissions:
466
488
  | Issue | Fix |
467
489
  |-------|-----|
468
490
  | "AppleScript error" | Enable "Allow JavaScript from Apple Events" in Safari → Develop |
491
+ | "Not authorized to send Apple events to Safari" | Grant Automation → Safari to your IDE (see above) |
469
492
  | Screenshots empty | Grant Screen Recording permission to Terminal/VS Code |
470
493
  | Tab not found | Call `safari_list_tabs` to refresh tab indices |
471
494
  | Hebrew keyboard issues | All typing uses JS events — immune to keyboard layout |
@@ -724,6 +724,11 @@ async function handleCommand(type, payload) {
724
724
  try {
725
725
  let view = pmEl.pmViewDesc && pmEl.pmViewDesc.view;
726
726
  if (!view) { const keys = Object.keys(pmEl); for (let i=0;i<keys.length;i++) { const o=pmEl[keys[i]]; if(o&&o.state&&o.dispatch){view=o;break;} } }
727
+ // Walk React Fiber tree to find EditorView (LinkedIn, Tiptap-React, etc.)
728
+ if (!view) {
729
+ const fk = Object.keys(pmEl).find(function(k){return k.startsWith("__reactFiber$")||k.startsWith("__reactInternalInstance$");});
730
+ if (fk) { let fiber = pmEl[fk]; for (let d=0;d<20&&fiber;d++) { const props = fiber.memoizedProps||(fiber.stateNode&&fiber.stateNode.props); if(props) { const v = props.editorView||props.view; if(v&&v.state&&v.dispatch){view=v;break;} } fiber=fiber.return; } }
731
+ }
727
732
  if (view && view.state && view.dispatch) {
728
733
  const { state } = view;
729
734
  const doc = state.doc;
@@ -743,6 +748,14 @@ async function handleCommand(type, payload) {
743
748
  }
744
749
  } catch (e) { /* fall through */ }
745
750
  }
751
+ // ProseMirror detected but no view found — use char-by-char with beforeinput
752
+ if (!ceResult && pmEl) {
753
+ try {
754
+ el.focus();
755
+ (window.__mcpClosureType || function(){})(value, el);
756
+ ceResult = "Filled contenteditable (ProseMirror char-by-char, " + value.length + " chars)";
757
+ } catch (e) { /* fall through */ }
758
+ }
746
759
 
747
760
  // === Draft.js: use React fiber to access EditorState ===
748
761
  if (!ceResult) {
@@ -822,6 +835,7 @@ async function handleCommand(type, payload) {
822
835
  if (!ceResult) {
823
836
  document.execCommand("selectAll", false, null);
824
837
  document.execCommand("delete", false, null);
838
+ el.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertText", data: value, bubbles: true, cancelable: true }));
825
839
  document.execCommand("insertText", false, value);
826
840
  ceResult = "Filled contenteditable";
827
841
  }
@@ -37,42 +37,6 @@
37
37
  <span class="slider"></span>
38
38
  </label>
39
39
  </div>
40
- <script>
41
- // Inline script — cannot be cached separately by Safari
42
- function setStatus(c, t, i) {
43
- document.getElementById("statusDot").className = "dot " + c;
44
- document.getElementById("statusText").textContent = t;
45
- document.getElementById("info").textContent = i;
46
- }
47
- async function getVal(k, d) {
48
- try { const r = await browser.storage.local.get(k); return r[k] !== undefined ? r[k] : d; }
49
- catch { return d; }
50
- }
51
- async function check() {
52
- const on = await getVal("mcpEnabled", true);
53
- document.getElementById("enableToggle").checked = on;
54
- if (!on) { setStatus("paused", "Paused", "Toggle to resume"); return; }
55
- // Ask background script for real-time status first
56
- try {
57
- const resp = await browser.runtime.sendMessage({ action: "getStatus" });
58
- if (resp) {
59
- if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
60
- if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
61
- }
62
- } catch {}
63
- // Fallback: read from storage
64
- const s = await getVal("mcpStatus", null);
65
- if (s === "connected") setStatus("connected", "Connected", "Port 9224");
66
- else if (s === "paused") setStatus("paused", "Paused", "Toggle to resume");
67
- else if (s === "checking") setStatus("checking", "Connecting...", "Trying port 9224...");
68
- else setStatus("disconnected", "Not connected", "Start the MCP server to connect");
69
- }
70
- document.getElementById("enableToggle").addEventListener("change", async (e) => {
71
- await browser.storage.local.set({ mcpEnabled: e.target.checked });
72
- try { browser.runtime.sendMessage({ action: "setEnabled", enabled: e.target.checked }); } catch {}
73
- setTimeout(check, 500);
74
- });
75
- check();
76
- </script>
40
+ <script src="popup.js"></script>
77
41
  </body>
78
42
  </html>
@@ -0,0 +1,34 @@
1
+ function setStatus(c, t, i) {
2
+ document.getElementById("statusDot").className = "dot " + c;
3
+ document.getElementById("statusText").textContent = t;
4
+ document.getElementById("info").textContent = i;
5
+ }
6
+ async function getVal(k, d) {
7
+ try { const r = await browser.storage.local.get(k); return r[k] !== undefined ? r[k] : d; }
8
+ catch { return d; }
9
+ }
10
+ async function check() {
11
+ const on = await getVal("mcpEnabled", true);
12
+ document.getElementById("enableToggle").checked = on;
13
+ if (!on) { setStatus("paused", "Paused", "Toggle to resume"); return; }
14
+ // Ask background script for real-time status first
15
+ try {
16
+ const resp = await browser.runtime.sendMessage({ action: "getStatus" });
17
+ if (resp) {
18
+ if (resp.connected) { setStatus("connected", "Connected", "Port 9224"); return; }
19
+ if (!resp.enabled) { setStatus("paused", "Paused", "Toggle to resume"); return; }
20
+ }
21
+ } catch {}
22
+ // Fallback: read from storage
23
+ const s = await getVal("mcpStatus", null);
24
+ if (s === "connected") setStatus("connected", "Connected", "Port 9224");
25
+ else if (s === "paused") setStatus("paused", "Paused", "Toggle to resume");
26
+ else if (s === "checking") setStatus("checking", "Connecting...", "Trying port 9224...");
27
+ else setStatus("disconnected", "Not connected", "Start the MCP server to connect");
28
+ }
29
+ document.getElementById("enableToggle").addEventListener("change", async (e) => {
30
+ await browser.storage.local.set({ mcpEnabled: e.target.checked });
31
+ try { browser.runtime.sendMessage({ action: "setEnabled", enabled: e.target.checked }); } catch {}
32
+ setTimeout(check, 500);
33
+ });
34
+ check();
package/index.js CHANGED
@@ -147,7 +147,7 @@ let _lastMemoryWarningTime = 0;
147
147
 
148
148
  function _getWebKitMemoryMB() {
149
149
  try {
150
- const pids = execFileSync("pgrep", ["-Ef", "WebKit|WebContent"], { encoding: "utf8" }).trim();
150
+ const pids = execFileSync("pgrep", ["-f", "WebKit|WebContent"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
151
151
  if (!pids) return 0;
152
152
  const pidList = pids.split("\n").join(",");
153
153
  const psOut = execFileSync("ps", ["-p", pidList, "-o", "rss="], { encoding: "utf8" }).trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.7.7",
3
+ "version": "2.7.9",
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",
@@ -71,5 +71,9 @@
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "^1.12.1",
73
73
  "ws": "^8.19.0"
74
+ },
75
+ "overrides": {
76
+ "hono": "^4.12.12",
77
+ "@hono/node-server": "^1.19.13"
74
78
  }
75
79
  }
package/safari-helper CHANGED
Binary file
package/safari.js CHANGED
@@ -1131,7 +1131,12 @@ export async function fill({ selector, value, ref }) {
1131
1131
  const result = await runJS(
1132
1132
  `(function(){try{var el=document.querySelector('${sel}');if(!el){var q=function(r){var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++){if(a[i].shadowRoot){el=a[i].shadowRoot.querySelector('${sel}');if(el)return el;el=q(a[i].shadowRoot);if(el)return el;}}return null;};el=q(document);}if(!el)return 'Element not found: ${sel}';el.focus();if(el.isContentEditable||el.getAttribute('contenteditable')==='true'){` +
1133
1133
  // ProseMirror detection
1134
- `var pm=el.closest('.ProseMirror')||el.querySelector('.ProseMirror');if(pm){try{var v=null;if(pm.pmViewDesc&&pm.pmViewDesc.view)v=pm.pmViewDesc.view;else if(pm.cmView&&pm.cmView.view)v=pm.cmView.view;else{var keys=Object.keys(pm);for(var ki=0;ki<keys.length;ki++){var o=pm[keys[ki]];if(o&&o.state&&o.dispatch){v=o;break;}}}if(v&&v.state&&v.dispatch){var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));v.focus();return 'Filled CE (ProseMirror append)';}else{var tr=v.state.tr;tr.replaceWith(0,doc.content.size,v.state.schema.text('${val}'));v.dispatch(tr);v.focus();return 'Filled CE (ProseMirror replace)';}}}catch(e){}}` +
1134
+ `var pm=el.closest('.ProseMirror')||el.querySelector('.ProseMirror');if(pm){try{var v=null;if(pm.pmViewDesc&&pm.pmViewDesc.view)v=pm.pmViewDesc.view;else if(pm.cmView&&pm.cmView.view)v=pm.cmView.view;else{var keys=Object.keys(pm);for(var ki=0;ki<keys.length;ki++){var o=pm[keys[ki]];if(o&&o.state&&o.dispatch){v=o;break;}}}` +
1135
+ // React Fiber walk for ProseMirror view (LinkedIn, Tiptap-React)
1136
+ `if(!v){var fk=Object.keys(pm).find(function(k){return k.startsWith('__reactFiber$')||k.startsWith('__reactInternalInstance$');});if(fk){var fiber=pm[fk];for(var d=0;d<20&&fiber;d++){var props=fiber.memoizedProps||(fiber.stateNode&&fiber.stateNode.props);if(props){var pv=props.editorView||props.view;if(pv&&pv.state&&pv.dispatch){v=pv;break;}}fiber=fiber.return;}}}` +
1137
+ `if(v&&v.state&&v.dispatch){var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));v.focus();return 'Filled CE (ProseMirror append)';}else{var tr=v.state.tr;tr.replaceWith(0,doc.content.size,v.state.schema.text('${val}'));v.dispatch(tr);v.focus();return 'Filled CE (ProseMirror replace)';}}}catch(e){}}` +
1138
+ // ProseMirror detected but no view — use char-by-char with beforeinput
1139
+ `if(pm&&!v){return '__PM_CHARBYCHAR__';}` +
1135
1140
  // Closure/Medium detection — signal for native paste
1136
1141
  `var isClosure=Object.keys(el).some(function(k){return k.startsWith('closure_uid_');})||location.hostname.includes('medium.com');if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
1137
1142
  // Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
@@ -1143,6 +1148,42 @@ export async function fill({ selector, value, ref }) {
1143
1148
  `var t=el._valueTracker;if(t)t.setValue('');var proto=el.tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;var s=Object.getOwnPropertyDescriptor(proto,'value');if(s&&s.set){s.set.call(el,'${val}');}else{el.value='${val}';}el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('blur',{bubbles:true}));el.dispatchEvent(new Event('focusout',{bubbles:true}));el.focus();return 'Filled: '+el.value.substring(0,50);}catch(e){return 'ERR: '+e.message;}})()`
1144
1149
  );
1145
1150
 
1151
+ // ProseMirror editor with no view access: use char-by-char with beforeinput events
1152
+ if (result === "__PM_CHARBYCHAR__") {
1153
+ const rawValue = value;
1154
+ const lines = rawValue.split('\n');
1155
+ const charInserts = lines.map((line, i) => {
1156
+ const escaped = line.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1157
+ let cmds = '';
1158
+ if (i > 0) {
1159
+ cmds += `t.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',keyCode:13,code:'Enter',bubbles:true,cancelable:true}));`;
1160
+ cmds += `t.dispatchEvent(new InputEvent('beforeinput',{inputType:'insertParagraph',bubbles:true,cancelable:true}));`;
1161
+ cmds += `document.execCommand('insertParagraph');`;
1162
+ cmds += `t.dispatchEvent(new InputEvent('input',{inputType:'insertParagraph',bubbles:true}));`;
1163
+ cmds += `t.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',keyCode:13,code:'Enter',bubbles:true}));`;
1164
+ }
1165
+ if (escaped.length > 0) {
1166
+ // Insert text in one beforeinput + execCommand, then fire input
1167
+ cmds += `t.dispatchEvent(new InputEvent('beforeinput',{data:'${escaped}',inputType:'insertText',bubbles:true,cancelable:true}));`;
1168
+ cmds += `document.execCommand('insertText',false,'${escaped}');`;
1169
+ cmds += `t.dispatchEvent(new InputEvent('input',{data:'${escaped}',inputType:'insertText',bubbles:true}));`;
1170
+ }
1171
+ return cmds;
1172
+ }).join('');
1173
+
1174
+ const fillResult = await runJS(
1175
+ `(function(){` +
1176
+ `var el=document.querySelector('${selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}');` +
1177
+ `if(!el)return 'Element not found';` +
1178
+ `el.focus();el.click();` +
1179
+ `var t=document.activeElement||el;` +
1180
+ charInserts +
1181
+ `return 'Filled CE (ProseMirror char-by-char)';` +
1182
+ `})()`
1183
+ );
1184
+ return fillResult;
1185
+ }
1186
+
1146
1187
  // Closure/Medium editor: insert line-by-line via execCommand in small batches
1147
1188
  // No focus stealing, no System Events, no clipboard manipulation.
1148
1189
  // Medium's Closure editor accepts execCommand('insertText') for individual lines