UNPKG

camoufox-mcp-server

Version:

MCP server for browser automation using Camoufox - a privacy-focused Firefox fork with advanced anti-detection features

162 lines (161 loc) 7.89 kB
import chalk from "chalk"; import { ALLOW_EVALUATE, DEFAULT_ACTION_TIMEOUT_MS, SEQUENCE_TIMEOUT_MS } from "./config.js"; import { describeError, serializeBounded, withTimeout } from "./utils.js"; import { settleAndAssertSafe } from "./browser-runtime.js"; export function actionTimeout(action) { return action.timeout ?? DEFAULT_ACTION_TIMEOUT_MS; } export function sequenceTimeoutBudget(actions) { return actions.reduce((total, action) => total + actionTimeout(action), 0); } export function isLocalOperationTimeout(error) { return describeError(error).endsWith(" timed out."); } export function resolveLocator(page, selector, frame) { if (frame) return page.frameLocator(frame).locator(selector).first(); return page.locator(selector).first(); } export async function pointerClick(locator, timeout) { await locator.click({ timeout }); } export async function domClick(locator, timeout) { // Camoufox's virtual display can hang during low-level mouse clicks in CI. // Keep this as DOM activation, without Playwright's stability-gated scroll // or pointer hit-testing, until mouse actions are stable under Xvfb. await withTimeout(locator.evaluate((element) => { const clickable = element; if (typeof clickable.click === "function") { clickable.click(); return; } element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window, })); }), timeout, "Click action"); } export async function activateElement(page, selector, timeout, frame, clickMode = "dom") { const locator = resolveLocator(page, selector, frame); await locator.waitFor({ state: "visible", timeout }); if (!await locator.isEnabled({ timeout })) { throw new Error(`Click selector is disabled: ${selector}`); } if (clickMode === "pointer") { await pointerClick(locator, timeout); return; } if (clickMode === "auto") { try { await pointerClick(locator, timeout); return; } catch (error) { console.error(chalk.yellow(`[Camoufox] Pointer click failed, falling back to DOM click: ${describeError(error)}`)); } } await domClick(locator, timeout); } export async function runSequenceAction(page, action, index, rawUrls, secrets) { const started = Date.now(); const timeout = actionTimeout(action); switch (action.type) { case "click": await activateElement(page, action.selector, timeout, action.frame, action.clickMode); return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "hover": await resolveLocator(page, action.selector, action.frame).hover({ timeout }); return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "fill": await resolveLocator(page, action.selector, action.frame).fill(action.value, { timeout }); return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "type": await resolveLocator(page, action.selector, action.frame).pressSequentially(action.text, { delay: action.delay, timeout, }); return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "select": await resolveLocator(page, action.selector, action.frame).selectOption(action.value, { timeout }); return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "press": if (action.selector) { await resolveLocator(page, action.selector, action.frame).press(action.key, { timeout }); } else { await withTimeout(page.keyboard.press(action.key), timeout, "Press action"); } return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "waitFor": if (action.selector) { if (action.frame) { await resolveLocator(page, action.selector, action.frame).waitFor({ state: action.state, timeout }); } else { await page.waitForSelector(action.selector, { state: action.state, timeout }); } } else { await page.waitForLoadState(action.loadState ?? "load", { timeout }); } return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "scroll": if (action.selector) { const locator = resolveLocator(page, action.selector, action.frame); await locator.waitFor({ state: "attached", timeout }); await withTimeout(locator.evaluate(async (element, { deltaX, deltaY }) => { const target = element; const beforeLeft = target.scrollLeft; const beforeTop = target.scrollTop; let scrollEventFired = false; await new Promise((resolve) => { const timer = window.setTimeout(() => resolve(), 100); target.addEventListener("scroll", () => { scrollEventFired = true; window.clearTimeout(timer); resolve(); }, { once: true }); target.scrollBy(deltaX, deltaY); if (target.scrollLeft === beforeLeft && target.scrollTop === beforeTop) { window.clearTimeout(timer); resolve(); } }); if (!scrollEventFired && (target.scrollLeft !== beforeLeft || target.scrollTop !== beforeTop)) { target.dispatchEvent(new Event("scroll", { bubbles: true })); } }, { deltaX: action.deltaX, deltaY: action.deltaY }), timeout, "Scroll action"); } else { await page.mouse.wheel(action.deltaX, action.deltaY); } return { index, type: action.type, selector: action.selector, status: "ok", durationMs: Date.now() - started }; case "evaluate": { if (!ALLOW_EVALUATE) { throw new Error("Evaluate action is disabled by server policy. Set CAMOUFOX_MCP_ALLOW_EVALUATE=1 to enable it."); } const result = await withTimeout(page.evaluate((expression) => globalThis.eval(expression), action.expression), timeout, "Evaluate action"); const serialized = serializeBounded(result, action.maxChars, rawUrls, secrets); return { index, type: action.type, status: "ok", result: serialized.value, resultTruncated: serialized.truncated, durationMs: Date.now() - started, }; } } } export async function runSequenceActionsWithBudget(page, requestGuard, actionsInput, rawUrls, secrets) { const actions = []; await withTimeout((async () => { for (let index = 0; index < actionsInput.length; index += 1) { const result = await runSequenceAction(page, actionsInput[index], index, rawUrls, secrets); actions.push(result); await settleAndAssertSafe(page, requestGuard); } })(), SEQUENCE_TIMEOUT_MS, "Browse sequence"); return actions; }