mcp-camoufox 0.5.3 → 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.
- package/README.md +39 -4
- package/dist/index.js +296 -0
- 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. **
|
|
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,7 @@ 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)** | **
|
|
42
|
+
| **[mcp-camoufox](https://github.com/RobithYusuf/mcp-camoufox)** | **96** | **Yes** | **Yes** | **Yes** |
|
|
43
43
|
|
|
44
44
|
## Proven on Real Sites
|
|
45
45
|
|
|
@@ -262,7 +262,7 @@ Or via UI: Agent Panel > `...` > MCP Servers > Manage MCP Servers > View raw con
|
|
|
262
262
|
|
|
263
263
|
That's all. Camoufox browser binary (~80MB) downloads automatically on first launch.
|
|
264
264
|
|
|
265
|
-
## All
|
|
265
|
+
## All 96 Tools
|
|
266
266
|
|
|
267
267
|
### Browser Lifecycle (2)
|
|
268
268
|
|
|
@@ -441,12 +441,47 @@ That's all. Camoufox browser binary (~80MB) downloads automatically on first lau
|
|
|
441
441
|
| `find_by_label` | Find input by label text, returns ref. |
|
|
442
442
|
| `find_by_placeholder` | Find input by placeholder, returns ref. |
|
|
443
443
|
|
|
444
|
-
### Session Portability (
|
|
444
|
+
### Session Portability (5)
|
|
445
445
|
|
|
446
446
|
| Tool | Description |
|
|
447
447
|
|------|-------------|
|
|
448
448
|
| `cookie_export` | Export all cookies as JSON (for transfer) |
|
|
449
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` |
|
|
450
485
|
|
|
451
486
|
### Scraping & Extraction (4)
|
|
452
487
|
|
package/dist/index.js
CHANGED
|
@@ -1491,6 +1491,302 @@ server.tool("page_stats", "Page statistics: element count, size, load metrics. U
|
|
|
1491
1491
|
})()`);
|
|
1492
1492
|
return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }] };
|
|
1493
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
|
+
});
|
|
1494
1790
|
// ── Start Server ───────────────────────────────────────────────────────────
|
|
1495
1791
|
async function main() {
|
|
1496
1792
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED