mcp-camoufox 0.5.2 → 0.6.1

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.
Files changed (3) hide show
  1. package/README.md +71 -7
  2. package/dist/index.js +357 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  </div>
13
13
 
14
- The most feature-rich stealth browser MCP server. **79 tools** for full browser control powered by [Camoufox](https://github.com/daijro/camoufox) — a Firefox fork with C++ level anti-detection that bypasses Cloudflare, bot detection, and anti-automation.
14
+ The most feature-rich stealth browser MCP server. **96 tools** for full browser control powered by [Camoufox](https://github.com/daijro/camoufox) — a Firefox fork with C++ level anti-detection that bypasses Cloudflare, bot detection, and anti-automation.
15
15
 
16
16
  > **One command. No Python. No manual setup. Everything auto-installs.**
17
17
 
@@ -39,7 +39,35 @@ claude mcp add camoufox -- npx -y mcp-camoufox@latest
39
39
  | redf0x1/camofox-mcp | 45 | Yes | No (clone) | Yes |
40
40
  | Sekinal/camoufox-mcp | 49 | Yes | No (clone) | Yes |
41
41
  | Playwright CLI | 60+ | No | Yes | Yes |
42
- | **[mcp-camoufox](https://github.com/RobithYusuf/mcp-camoufox)** | **79** | **Yes** | **Yes** | **Yes** |
42
+ | **[mcp-camoufox](https://github.com/RobithYusuf/mcp-camoufox)** | **96** | **Yes** | **Yes** | **Yes** |
43
+
44
+ ## Proven on Real Sites
45
+
46
+ | Site | Challenge | Result |
47
+ |------|-----------|--------|
48
+ | `2captcha.com/demo/cloudflare-turnstile` | Cloudflare Turnstile widget | ✅ **"Success!"** via `click_turnstile()` tool ([proof](docs/images/turnstile.jpg)) |
49
+ | `bot.sannysoft.com` | Firefox fingerprint tests | ✅ All green ([proof](docs/images/sannysoft.jpg)) |
50
+ | `browserscan.net/bot-detection` | WebDriver/UA/CDP/Navigator | ✅ All categories "Normal" ([proof](docs/images/browserscan.jpg)) |
51
+
52
+ ### 🎯 Cloudflare Turnstile → Success via `click_turnstile()`
53
+
54
+ <img src="docs/images/turnstile.jpg" alt="Cloudflare Turnstile success" width="500">
55
+
56
+ `click_turnstile()` auto-detects the widget via 6 selector fallback (`iframe[src*=challenges.cloudflare.com]`, `[data-sitekey]`, `.cf-turnstile`, …), computes checkbox position (offset_x=30 from widget left), and clicks with a 3-step Bezier-like approach — combined with Camoufox's native `humanize` + `disable_coop` for cross-origin iframe click.
57
+
58
+ **Scope:** works on **Interactive Turnstile** (visible iframe widget). **Managed Challenge** interstitials ("Just a moment...") render the widget in shadow DOM — not supported here; use sister project [mcp-stealth-chrome](https://github.com/RobithYusuf/mcp-stealth-chrome) (Chrome+CDP) for those. Real-world bypass success also depends on IP reputation and browser fingerprint — code alone doesn't guarantee it.
59
+
60
+ ### 🧪 bot.sannysoft.com → Firefox Fingerprint Pass
61
+
62
+ <img src="docs/images/sannysoft.jpg" alt="sannysoft Firefox pass" width="500">
63
+
64
+ User Agent reports `Firefox/135.0`, WebDriver missing, WebDriver Advanced passed, Permissions prompt, Plugins length 5 passed, Languages `en-US,en`, WebGL Intel HD Graphics — all green. ("Chrome: missing" is expected — Camoufox spoofs Firefox, not Chrome.)
65
+
66
+ ### 🔍 browserscan.net/bot-detection → All Categories Normal
67
+
68
+ <img src="docs/images/browserscan.jpg" alt="browserscan normal" width="500">
69
+
70
+ WebDriver, User-Agent, CDP, Navigator — every detection category returns **"Normal"**. Camoufox's C++-level Firefox patches leave zero automation signals.
43
71
 
44
72
  ## Setup
45
73
 
@@ -234,7 +262,7 @@ Or via UI: Agent Panel > `...` > MCP Servers > Manage MCP Servers > View raw con
234
262
 
235
263
  That's all. Camoufox browser binary (~80MB) downloads automatically on first launch.
236
264
 
237
- ## All 79 Tools
265
+ ## All 96 Tools
238
266
 
239
267
  ### Browser Lifecycle (2)
240
268
 
@@ -283,12 +311,13 @@ That's all. Camoufox browser binary (~80MB) downloads automatically on first lau
283
311
  | `type_text` | Type char by char. Options: `delay`. For OTP, masked inputs, date pickers. |
284
312
  | `press_key` | Key or combo: `Enter`, `Escape`, `Tab`, `Control+a`, `Meta+c` |
285
313
 
286
- ### Mouse XY (3)
314
+ ### Mouse XY (4)
287
315
 
288
316
  | Tool | Description |
289
317
  |------|-------------|
290
- | `mouse_click_xy` | Click at exact coordinates |
291
- | `mouse_move` | Move cursor to coordinates |
318
+ | `mouse_click_xy` | Click at exact coordinates. Optional `steps` (0=instant, 15-30=human-like pre-movement) |
319
+ | `mouse_move` | Move cursor to coordinates. Optional `steps` for interpolated path |
320
+ | `click_turnstile` | Auto-find + humanized click on Cloudflare Turnstile widget. Params: `offset_x` (default 30), `offset_y`, `wait_render_ms`. Works on Interactive Turnstile (visible iframe widget). Not for Managed Challenge interstitials. |
292
321
  | `drag_and_drop` | Drag between two elements |
293
322
 
294
323
  ### Wait (4)
@@ -412,12 +441,47 @@ That's all. Camoufox browser binary (~80MB) downloads automatically on first lau
412
441
  | `find_by_label` | Find input by label text, returns ref. |
413
442
  | `find_by_placeholder` | Find input by placeholder, returns ref. |
414
443
 
415
- ### Session Portability (2)
444
+ ### Session Portability (5)
416
445
 
417
446
  | Tool | Description |
418
447
  |------|-------------|
419
448
  | `cookie_export` | Export all cookies as JSON (for transfer) |
420
449
  | `cookie_import` | Import cookies from JSON (restore session) |
450
+ | `storage_state_save` | Save cookies + localStorage + sessionStorage to JSON file. Reload to skip login/CF. |
451
+ | `storage_state_load` | Restore session from JSON (cookies + storage). Use `navigate_to` param to apply localStorage. |
452
+ | `auth_capture` | Convenience: save current session to `~/.camoufox-mcp/sessions/<name>.json` |
453
+
454
+ ### Humanize / Anti-Bot (4)
455
+
456
+ | Tool | Description |
457
+ |------|-------------|
458
+ | `humanize_click` | 3-step Bezier mouse approach + small jitter before click. Use for CF/DataDome pages. |
459
+ | `humanize_type` | Gaussian-distributed keystroke delays (mean 80ms, sigma 30ms). Mimics human rhythm. |
460
+ | `mouse_drift` | Random mouse movements over duration — builds mouse history before action. |
461
+ | `mouse_record` / `mouse_replay` | Capture human mouse path then replay (anti-bot gold). |
462
+
463
+ ### Session Warmup & Detection (2)
464
+
465
+ | Tool | Description |
466
+ |------|-------------|
467
+ | `session_warmup` | Visit Google/Wikipedia (random) before targeting protected site. Helps IP scoring. |
468
+ | `detect_anti_bot` | Heuristic detection of CF/DataDome/Akamai/PerimeterX/Imperva/reCAPTCHA/hCaptcha. |
469
+
470
+ ### Assertions (3)
471
+
472
+ | Tool | Description |
473
+ |------|-------------|
474
+ | `assert_element_visible` | PASS/FAIL — element exists and is visible |
475
+ | `assert_text_present` | PASS/FAIL — text substring on page |
476
+ | `assert_url_matches` | PASS/FAIL — URL matches pattern (substring or regex) |
477
+
478
+ ### Workflow Helpers (3)
479
+
480
+ | Tool | Description |
481
+ |------|-------------|
482
+ | `click_and_wait` | Click + wait for navigation/selector atomically (fewer roundtrips) |
483
+ | `wait_for_network_idle` | Wait until no in-flight requests for N ms (better than fixed timeouts for SPAs) |
484
+ | `describe_page` | Compact LLM-friendly summary (title, h1, buttons, links, forms) — cheaper than `browser_snapshot` |
421
485
 
422
486
  ### Scraping & Extraction (4)
423
487
 
package/dist/index.js CHANGED
@@ -804,21 +804,74 @@ server.tool("sessionstorage_set", "Set a sessionStorage item.", {
804
804
  return { content: [{ type: "text", text: `sessionStorage set: ${key}` }] };
805
805
  });
806
806
  // ── Tools: Mouse XY ────────────────────────────────────────────────────────
807
- server.tool("mouse_click_xy", "Click at exact x,y coordinates on the page.", {
807
+ server.tool("mouse_click_xy", "Click at exact x,y coordinates. steps>0 adds interpolated pre-movement (human-like).", {
808
808
  x: z.number(), y: z.number(),
809
809
  button: z.enum(["left", "right", "middle"]).default("left"),
810
- }, async ({ x, y, button }) => {
810
+ steps: z.number().default(0).describe("Interpolation steps for pre-click movement (0=instant, 15-30=human-like)"),
811
+ }, async ({ x, y, button, steps }) => {
811
812
  const page = getPage();
813
+ if (steps > 0) {
814
+ await page.mouse.move(x, y, { steps });
815
+ await page.waitForTimeout(80 + Math.random() * 60);
816
+ }
812
817
  await page.mouse.click(x, y, { button });
813
818
  await page.waitForTimeout(500);
814
- return { content: [{ type: "text", text: `Clicked at (${x}, ${y}) button=${button}` }] };
819
+ return { content: [{ type: "text", text: `Clicked at (${x}, ${y}) button=${button} steps=${steps}` }] };
815
820
  });
816
- server.tool("mouse_move", "Move mouse to exact x,y coordinates.", {
821
+ server.tool("mouse_move", "Move mouse to x,y. steps>0 interpolates path (human-like).", {
817
822
  x: z.number(), y: z.number(),
818
- }, async ({ x, y }) => {
819
- const page = getPage();
820
- await page.mouse.move(x, y);
821
- return { content: [{ type: "text", text: `Mouse moved to (${x}, ${y})` }] };
823
+ steps: z.number().default(0).describe("Interpolation steps (0=instant jump, 15-30=smooth)"),
824
+ }, async ({ x, y, steps }) => {
825
+ const page = getPage();
826
+ await page.mouse.move(x, y, steps > 0 ? { steps } : undefined);
827
+ return { content: [{ type: "text", text: `Mouse moved to (${x}, ${y}) steps=${steps}` }] };
828
+ });
829
+ server.tool("click_turnstile", "Auto-find and click Cloudflare Turnstile checkbox. Port of mcp-stealth-chrome's proven pattern — single pre-drift + direct click, leaning on Camoufox's built-in humanize + disable_coop for cross-origin iframe support. Works on Interactive Turnstile widgets (visible iframe). Managed Challenge interstitials not supported — use mcp-stealth-chrome for those.", {
830
+ offset_x: z.number().default(30).describe("Pixels from widget left edge (calibrated for CF checkbox)"),
831
+ offset_y: z.number().optional().describe("Vertical offset from widget top (default = height/2)"),
832
+ wait_render_ms: z.number().default(500).describe("Wait before detection to let widget render"),
833
+ }, async ({ offset_x, offset_y, wait_render_ms }) => {
834
+ const page = getPage();
835
+ await page.waitForTimeout(wait_render_ms);
836
+ // Widget detection — 6 selectors ordered by specificity (port from mcp-stealth-chrome)
837
+ const coords = await page.evaluate(() => {
838
+ const sels = [
839
+ 'iframe[src*="challenges.cloudflare.com"]',
840
+ 'iframe[src*="turnstile"]',
841
+ '[data-testid*="challenge-widget"]',
842
+ '[data-testid*="turnstile"]',
843
+ '[data-sitekey]',
844
+ '.cf-turnstile',
845
+ ];
846
+ for (const sel of sels) {
847
+ const el = document.querySelector(sel);
848
+ if (!el)
849
+ continue;
850
+ const r = el.getBoundingClientRect();
851
+ if (r.width < 50 || r.height < 20)
852
+ continue;
853
+ return {
854
+ found: sel,
855
+ left: Math.round(r.left),
856
+ top: Math.round(r.top),
857
+ width: Math.round(r.width),
858
+ height: Math.round(r.height),
859
+ };
860
+ }
861
+ return null;
862
+ });
863
+ if (!coords) {
864
+ return { content: [{ type: "text", text: "Turnstile widget not found — selector miss. Likely a Managed Challenge interstitial (use mcp-stealth-chrome) or widget hasn't rendered yet (try wait_render_ms=3000)." }] };
865
+ }
866
+ const targetX = coords.left + offset_x;
867
+ const targetY = coords.top + (offset_y ?? Math.floor(coords.height / 2));
868
+ // Single pre-drift then direct click (matches stealth-chrome's pattern).
869
+ // Camoufox's humanize layer handles path curvature + timing automatically,
870
+ // so extra Bezier hops would be redundant and slow (~3s extra).
871
+ await page.mouse.move(targetX + 180, targetY - 80, { steps: 15 });
872
+ await page.waitForTimeout(150);
873
+ await page.mouse.click(targetX, targetY);
874
+ return { content: [{ type: "text", text: `clicked Turnstile at (${targetX},${targetY}) — widget found via ${coords.found} (${coords.width}x${coords.height})` }] };
822
875
  });
823
876
  server.tool("drag_and_drop", "Drag from one element to another.", {
824
877
  source_ref: z.string().describe("Ref of element to drag"),
@@ -1438,6 +1491,302 @@ server.tool("page_stats", "Page statistics: element count, size, load metrics. U
1438
1491
  })()`);
1439
1492
  return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }] };
1440
1493
  });
1494
+ // ── Tools: Storage State (Session Reuse) ───────────────────────────────────
1495
+ server.tool("storage_state_save", "Save cookies + localStorage to a JSON file. Reload via storage_state_load on a fresh browser to skip login/CF entirely.", {
1496
+ path: z.string().describe("Output file path (e.g. ~/.camoufox-mcp/sessions/site.json)"),
1497
+ }, async ({ path }) => {
1498
+ const page = getPage();
1499
+ const ctx = page.context();
1500
+ const cookies = await ctx.cookies();
1501
+ const origins = await page.evaluate(`(() => {
1502
+ var data = { local: {}, session: {} };
1503
+ for (var i = 0; i < localStorage.length; i++) {
1504
+ var k = localStorage.key(i); data.local[k] = localStorage.getItem(k);
1505
+ }
1506
+ for (var j = 0; j < sessionStorage.length; j++) {
1507
+ var k = sessionStorage.key(j); data.session[k] = sessionStorage.getItem(k);
1508
+ }
1509
+ return { url: location.href, origin: location.origin, ...data };
1510
+ })()`);
1511
+ const target = path.replace("~", process.env.HOME || "");
1512
+ const dir = target.substring(0, target.lastIndexOf("/"));
1513
+ if (dir)
1514
+ mkdirSync(dir, { recursive: true });
1515
+ writeFileSync(target, JSON.stringify({ cookies, origins: [origins] }, null, 2));
1516
+ return { content: [{ type: "text", text: `Saved storage state: ${target} (${cookies.length} cookies, ${Object.keys(origins.local || {}).length} localStorage, ${Object.keys(origins.session || {}).length} sessionStorage)` }] };
1517
+ });
1518
+ server.tool("storage_state_load", "Load cookies + localStorage from a JSON file (created by storage_state_save). Bypass CF/login if session is fresh.", {
1519
+ path: z.string().describe("Path to storage state JSON file"),
1520
+ navigate_to: z.string().optional().describe("URL to navigate to after loading (recommended — localStorage requires same-origin)"),
1521
+ }, async ({ path, navigate_to }) => {
1522
+ const page = getPage();
1523
+ const ctx = page.context();
1524
+ const target = path.replace("~", process.env.HOME || "");
1525
+ const data = JSON.parse((await import("fs")).readFileSync(target, "utf8"));
1526
+ if (data.cookies && data.cookies.length)
1527
+ await ctx.addCookies(data.cookies);
1528
+ let lsCount = 0, ssCount = 0;
1529
+ if (navigate_to) {
1530
+ await page.goto(navigate_to, { waitUntil: "domcontentloaded" });
1531
+ const origin = data.origins?.[0] || {};
1532
+ if (origin.local || origin.session) {
1533
+ await page.evaluate(`((data) => {
1534
+ if (data.local) Object.entries(data.local).forEach(([k, v]) => { try { localStorage.setItem(k, v); } catch {} });
1535
+ if (data.session) Object.entries(data.session).forEach(([k, v]) => { try { sessionStorage.setItem(k, v); } catch {} });
1536
+ })(${JSON.stringify(origin)})`);
1537
+ lsCount = Object.keys(origin.local || {}).length;
1538
+ ssCount = Object.keys(origin.session || {}).length;
1539
+ }
1540
+ }
1541
+ return { content: [{ type: "text", text: `Loaded storage state: ${data.cookies?.length || 0} cookies${navigate_to ? `, ${lsCount} localStorage, ${ssCount} sessionStorage (after navigate)` : " (call navigate to apply localStorage)"}` }] };
1542
+ });
1543
+ server.tool("auth_capture", "Save current session as named auth state (e.g. logged-in user). Convenience wrapper: storage_state_save to ~/.camoufox-mcp/sessions/<name>.json", {
1544
+ name: z.string().describe("Session name (e.g. 'github-bob', 'shopify-mystore')"),
1545
+ }, async ({ name }) => {
1546
+ const page = getPage();
1547
+ const ctx = page.context();
1548
+ const cookies = await ctx.cookies();
1549
+ const origins = await page.evaluate(`(() => {
1550
+ var data = { local: {}, session: {} };
1551
+ for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); data.local[k] = localStorage.getItem(k); }
1552
+ for (var j = 0; j < sessionStorage.length; j++) { var k = sessionStorage.key(j); data.session[k] = sessionStorage.getItem(k); }
1553
+ return { url: location.href, origin: location.origin, ...data };
1554
+ })()`);
1555
+ const dir = `${process.env.HOME}/.camoufox-mcp/sessions`;
1556
+ mkdirSync(dir, { recursive: true });
1557
+ const target = `${dir}/${name}.json`;
1558
+ writeFileSync(target, JSON.stringify({ cookies, origins: [origins] }, null, 2));
1559
+ return { content: [{ type: "text", text: `auth_capture saved: ${target}` }] };
1560
+ });
1561
+ // ── Tools: Cookie Bulk ─────────────────────────────────────────────────────
1562
+ server.tool("cookie_export_file", "Export all cookies to a JSON file (Playwright format).", {
1563
+ path: z.string().describe("Output JSON file path"),
1564
+ }, async ({ path }) => {
1565
+ const page = getPage();
1566
+ const cookies = await page.context().cookies();
1567
+ const target = path.replace("~", process.env.HOME || "");
1568
+ const dir = target.substring(0, target.lastIndexOf("/"));
1569
+ if (dir)
1570
+ mkdirSync(dir, { recursive: true });
1571
+ writeFileSync(target, JSON.stringify(cookies, null, 2));
1572
+ return { content: [{ type: "text", text: `Exported ${cookies.length} cookies to ${target}` }] };
1573
+ });
1574
+ server.tool("cookie_import_file", "Import cookies from a JSON file (Playwright format).", {
1575
+ path: z.string().describe("Input JSON file path"),
1576
+ }, async ({ path }) => {
1577
+ const page = getPage();
1578
+ const target = path.replace("~", process.env.HOME || "");
1579
+ const cookies = JSON.parse((await import("fs")).readFileSync(target, "utf8"));
1580
+ await page.context().addCookies(cookies);
1581
+ return { content: [{ type: "text", text: `Imported ${cookies.length} cookies from ${target}` }] };
1582
+ });
1583
+ // ── Tools: Humanize ────────────────────────────────────────────────────────
1584
+ server.tool("humanize_click", "Click element with humanized mouse approach (3-step Bezier-like curve before click). Use for anti-bot pages.", {
1585
+ ref: z.string().optional().describe("Element ref from snapshot"),
1586
+ selector: z.string().optional().describe("CSS selector"),
1587
+ }, async ({ ref, selector }) => {
1588
+ const page = getPage();
1589
+ const sel = ref ? `[data-mcp-ref="${ref}"]` : selector;
1590
+ if (!sel)
1591
+ return { content: [{ type: "text", text: "Error: ref or selector required" }] };
1592
+ const box = await page.locator(sel).first().boundingBox();
1593
+ if (!box)
1594
+ return { content: [{ type: "text", text: "Error: element has no bounding box" }] };
1595
+ const tx = box.x + box.width / 2 + (Math.random() * 8 - 4);
1596
+ const ty = box.y + box.height / 2 + (Math.random() * 6 - 3);
1597
+ await page.mouse.move(tx + 200, ty - 100, { steps: 20 });
1598
+ await page.waitForTimeout(180 + Math.random() * 120);
1599
+ await page.mouse.move(tx + 60, ty - 25, { steps: 12 });
1600
+ await page.waitForTimeout(120 + Math.random() * 80);
1601
+ await page.mouse.move(tx, ty, { steps: 8 });
1602
+ await page.waitForTimeout(70 + Math.random() * 50);
1603
+ await page.mouse.click(tx, ty);
1604
+ return { content: [{ type: "text", text: `humanize_click at (${Math.round(tx)},${Math.round(ty)})` }] };
1605
+ });
1606
+ server.tool("humanize_type", "Type text with Gaussian-distributed delays between keystrokes (mean ~80ms, sigma ~30ms). Mimics human typing rhythm.", {
1607
+ ref: z.string().optional(),
1608
+ selector: z.string().optional(),
1609
+ text: z.string().describe("Text to type"),
1610
+ mean_delay_ms: z.number().default(80),
1611
+ }, async ({ ref, selector, text, mean_delay_ms }) => {
1612
+ const page = getPage();
1613
+ const sel = ref ? `[data-mcp-ref="${ref}"]` : selector;
1614
+ if (sel)
1615
+ await page.locator(sel).first().focus();
1616
+ for (const ch of text) {
1617
+ await page.keyboard.type(ch);
1618
+ // Gaussian-ish delay (Box-Muller)
1619
+ const u1 = Math.max(0.0001, Math.random()), u2 = Math.random();
1620
+ const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
1621
+ const delay = Math.max(20, mean_delay_ms + z * (mean_delay_ms * 0.4));
1622
+ await page.waitForTimeout(delay);
1623
+ }
1624
+ return { content: [{ type: "text", text: `humanize_type typed ${text.length} chars` }] };
1625
+ });
1626
+ server.tool("mouse_drift", "Random mouse movements over a duration — builds up mouse history before action (CF/DataDome behavior analysis).", {
1627
+ duration_ms: z.number().default(2000).describe("Total drift duration"),
1628
+ points: z.number().default(5).describe("Number of random destinations"),
1629
+ }, async ({ duration_ms, points }) => {
1630
+ const page = getPage();
1631
+ const vp = page.viewportSize() || { width: 1280, height: 800 };
1632
+ const interval = duration_ms / points;
1633
+ for (let i = 0; i < points; i++) {
1634
+ const x = Math.floor(Math.random() * (vp.width - 100)) + 50;
1635
+ const y = Math.floor(Math.random() * (vp.height - 100)) + 50;
1636
+ await page.mouse.move(x, y, { steps: 12 });
1637
+ await page.waitForTimeout(interval * (0.7 + Math.random() * 0.6));
1638
+ }
1639
+ return { content: [{ type: "text", text: `mouse_drift: ${points} points over ${duration_ms}ms` }] };
1640
+ });
1641
+ server.tool("mouse_record", "Start recording mouse positions (call mouse_replay later). Returns recorder handle.", {
1642
+ duration_ms: z.number().default(5000),
1643
+ sample_rate_hz: z.number().default(30),
1644
+ }, async ({ duration_ms, sample_rate_hz }) => {
1645
+ const page = getPage();
1646
+ const handle = `rec-${Date.now()}`;
1647
+ await page.evaluate(`(() => {
1648
+ window.__mcp_mouse_rec = { points: [], start: Date.now() };
1649
+ var h = (e) => window.__mcp_mouse_rec.points.push({ x: e.clientX, y: e.clientY, t: Date.now() - window.__mcp_mouse_rec.start });
1650
+ window.__mcp_mouse_rec_handler = h;
1651
+ document.addEventListener('mousemove', h, { passive: true });
1652
+ setTimeout(() => document.removeEventListener('mousemove', window.__mcp_mouse_rec_handler), ${duration_ms});
1653
+ })()`);
1654
+ return { content: [{ type: "text", text: `mouse_record started: ${handle} (${duration_ms}ms, ~${sample_rate_hz}Hz). Move mouse manually then call mouse_replay.` }] };
1655
+ });
1656
+ server.tool("mouse_replay", "Replay last recorded mouse path with original timing.", {
1657
+ speed: z.number().default(1.0).describe("Replay speed multiplier (1.0=original, 2.0=2x faster)"),
1658
+ }, async ({ speed }) => {
1659
+ const page = getPage();
1660
+ const points = await page.evaluate(`(window.__mcp_mouse_rec?.points || [])`);
1661
+ if (!points.length)
1662
+ return { content: [{ type: "text", text: "No recording found — call mouse_record first" }] };
1663
+ let lastT = 0;
1664
+ for (const p of points) {
1665
+ const wait = (p.t - lastT) / speed;
1666
+ if (wait > 5)
1667
+ await page.waitForTimeout(wait);
1668
+ await page.mouse.move(p.x, p.y);
1669
+ lastT = p.t;
1670
+ }
1671
+ return { content: [{ type: "text", text: `mouse_replay: ${points.length} points` }] };
1672
+ });
1673
+ // ── Tools: Session Warmup & Anti-Bot Detection ─────────────────────────────
1674
+ server.tool("session_warmup", "Visit innocuous public sites (Google, Wikipedia) to build browsing history before targeting protected site. Helps with CF/DataDome IP scoring.", {
1675
+ duration_ms: z.number().default(10000).describe("Total warmup time"),
1676
+ sites: z.array(z.string()).optional().describe("URLs to visit (default: google.com, wikipedia.org)"),
1677
+ }, async ({ duration_ms, sites }) => {
1678
+ const page = getPage();
1679
+ const urls = sites && sites.length ? sites : [
1680
+ "https://www.google.com", "https://en.wikipedia.org/wiki/Special:Random",
1681
+ ];
1682
+ const per = Math.floor(duration_ms / urls.length);
1683
+ for (const url of urls) {
1684
+ try {
1685
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 });
1686
+ await page.waitForTimeout(per * 0.4);
1687
+ // Random scroll
1688
+ await page.mouse.wheel(0, 200 + Math.random() * 400).catch(() => { });
1689
+ await page.waitForTimeout(per * 0.3);
1690
+ }
1691
+ catch { }
1692
+ }
1693
+ return { content: [{ type: "text", text: `session_warmup: visited ${urls.length} sites over ${duration_ms}ms` }] };
1694
+ });
1695
+ server.tool("detect_anti_bot", "Heuristic detection of anti-bot vendor on current page (Cloudflare, DataDome, Akamai, PerimeterX, Imperva).", {}, async () => {
1696
+ const page = getPage();
1697
+ const result = await page.evaluate(`(() => {
1698
+ var html = document.documentElement.outerHTML.slice(0, 50000);
1699
+ var hits = [];
1700
+ if (/challenges\\.cloudflare|__cf_chl|cf-turnstile|turnstile/i.test(html) || /cloudflare/i.test(document.title)) hits.push("Cloudflare");
1701
+ if (/datadome|dd-captcha|js\\.datadome\\.co/i.test(html)) hits.push("DataDome");
1702
+ if (/akamai|akam\\.net|_bm\\.|bot-detector\\.akamai/i.test(html)) hits.push("Akamai");
1703
+ if (/perimeterx|px-captcha|_pxhd/i.test(html)) hits.push("PerimeterX");
1704
+ if (/imperva|incapsula/i.test(html)) hits.push("Imperva");
1705
+ if (/recaptcha|g-recaptcha|grecaptcha/i.test(html)) hits.push("reCAPTCHA");
1706
+ if (/hcaptcha/i.test(html)) hits.push("hCaptcha");
1707
+ return { vendors: hits, title: document.title, url: location.href };
1708
+ })()`);
1709
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1710
+ });
1711
+ // ── Tools: Assertions ──────────────────────────────────────────────────────
1712
+ server.tool("assert_element_visible", "Assert element exists and is visible. Returns success/fail (no throw).", {
1713
+ selector: z.string(),
1714
+ }, async ({ selector }) => {
1715
+ const page = getPage();
1716
+ try {
1717
+ const el = page.locator(selector).first();
1718
+ const visible = await el.isVisible({ timeout: 3000 });
1719
+ return { content: [{ type: "text", text: visible ? `PASS: ${selector} visible` : `FAIL: ${selector} not visible` }] };
1720
+ }
1721
+ catch (e) {
1722
+ return { content: [{ type: "text", text: `FAIL: ${selector} not found (${e.message?.slice(0, 80)})` }] };
1723
+ }
1724
+ });
1725
+ server.tool("assert_text_present", "Assert text is present anywhere on page (case-sensitive substring).", {
1726
+ text: z.string(),
1727
+ }, async ({ text }) => {
1728
+ const page = getPage();
1729
+ const body = await page.evaluate(`document.body.innerText`);
1730
+ const found = body.includes(text);
1731
+ return { content: [{ type: "text", text: found ? `PASS: '${text}' present` : `FAIL: '${text}' not found in body` }] };
1732
+ });
1733
+ server.tool("assert_url_matches", "Assert current URL matches pattern (substring or regex).", {
1734
+ pattern: z.string(),
1735
+ regex: z.boolean().default(false),
1736
+ }, async ({ pattern, regex }) => {
1737
+ const page = getPage();
1738
+ const url = page.url();
1739
+ const match = regex ? new RegExp(pattern).test(url) : url.includes(pattern);
1740
+ return { content: [{ type: "text", text: match ? `PASS: URL '${url}' matches '${pattern}'` : `FAIL: URL '${url}' does not match '${pattern}'` }] };
1741
+ });
1742
+ // ── Tools: Convenience / Workflow ──────────────────────────────────────────
1743
+ server.tool("click_and_wait", "Click element then wait for navigation or selector. Atomic — fewer roundtrips than separate click + wait_for.", {
1744
+ ref: z.string().optional(),
1745
+ selector: z.string().optional(),
1746
+ wait_for_url: z.string().optional().describe("URL substring to wait for after click"),
1747
+ wait_for_selector: z.string().optional().describe("Selector to wait for after click"),
1748
+ timeout_ms: z.number().default(15000),
1749
+ }, async ({ ref, selector, wait_for_url, wait_for_selector, timeout_ms }) => {
1750
+ const page = getPage();
1751
+ const sel = ref ? `[data-mcp-ref="${ref}"]` : selector;
1752
+ if (!sel)
1753
+ return { content: [{ type: "text", text: "Error: ref or selector required" }] };
1754
+ const beforeUrl = page.url();
1755
+ await Promise.all([
1756
+ page.locator(sel).first().click({ timeout: timeout_ms }),
1757
+ wait_for_url ? page.waitForURL((u) => u.toString().includes(wait_for_url), { timeout: timeout_ms }).catch(() => { }) :
1758
+ wait_for_selector ? page.waitForSelector(wait_for_selector, { timeout: timeout_ms }).catch(() => { }) :
1759
+ page.waitForLoadState("domcontentloaded", { timeout: timeout_ms }).catch(() => { }),
1760
+ ]);
1761
+ return { content: [{ type: "text", text: `click_and_wait: ${beforeUrl} → ${page.url()}` }] };
1762
+ });
1763
+ server.tool("wait_for_network_idle", "Wait until network is idle for N ms (no in-flight requests). Better than fixed timeouts for SPAs.", {
1764
+ idle_ms: z.number().default(500).describe("Idle threshold (Playwright default)"),
1765
+ timeout_ms: z.number().default(30000),
1766
+ }, async ({ idle_ms, timeout_ms }) => {
1767
+ const page = getPage();
1768
+ await page.waitForLoadState("networkidle", { timeout: timeout_ms });
1769
+ return { content: [{ type: "text", text: `network idle reached (>=${idle_ms}ms)` }] };
1770
+ });
1771
+ server.tool("describe_page", "Compact LLM-friendly page summary (title, heading, key buttons, forms). Cheaper than browser_snapshot for agent context.", {}, async () => {
1772
+ const page = getPage();
1773
+ const summary = await page.evaluate(`(() => {
1774
+ var title = document.title;
1775
+ var url = location.href;
1776
+ var h1 = document.querySelector('h1')?.innerText?.slice(0,100) || '';
1777
+ var h2s = Array.from(document.querySelectorAll('h2')).slice(0,5).map(h => h.innerText.slice(0,60));
1778
+ var buttons = Array.from(document.querySelectorAll('button, [role=button], input[type=submit]')).slice(0,10)
1779
+ .map(b => (b.innerText || b.value || '').trim().slice(0,40)).filter(t => t);
1780
+ var links = Array.from(document.querySelectorAll('a[href]')).slice(0,8)
1781
+ .map(a => ({ text: a.innerText.trim().slice(0,40), href: a.href.slice(0,80) })).filter(l => l.text);
1782
+ var forms = Array.from(document.querySelectorAll('form')).map(f => ({
1783
+ action: f.action?.slice(0,60),
1784
+ fields: Array.from(f.querySelectorAll('input, textarea, select')).slice(0,8).map(i => i.name || i.id || i.type),
1785
+ }));
1786
+ return { title, url, h1, h2s, buttons, links, forms };
1787
+ })()`);
1788
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
1789
+ });
1441
1790
  // ── Start Server ───────────────────────────────────────────────────────────
1442
1791
  async function main() {
1443
1792
  const transport = new StdioServerTransport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-camoufox",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "MCP server for stealth browser automation via Camoufox — 79 tools, Chrome DevTools MCP-level power with anti-bot stealth",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",