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
JavaScript
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;
}