camoufox-mcp-server
Version:
MCP server for browser automation using Camoufox - a privacy-focused Firefox fork with advanced anti-detection features
301 lines (300 loc) • 13.3 kB
JavaScript
import { randomUUID } from "node:crypto";
import chalk from "chalk";
import { validateTargetUrl } from "./policy.js";
import { DEFAULT_ACTION_TIMEOUT_MS, DEFAULT_MAX_CHARS, DEFAULT_MAX_ELEMENTS, MAX_SESSIONS, SESSION_CLOSE_GRACE_MS, SESSION_TTL_MS } from "./config.js";
import { acquireBrowserSlot, browserContextOptions, buildCamoufoxOptions, closeBrowser, installRequestGuard, launchCamoufoxBrowser, runGuardedPageRead, settleAndAssertSafe, trackBrowser, validateBrowserOptionsInput } from "./browser-runtime.js";
import { createDiagnosticsCollector } from "./diagnostics.js";
import { buildBrowsePayload, buildSnapshotPayload } from "./extractors.js";
import { maybeDetectCaptcha } from "./captcha.js";
import { buildSuccessContent, buildToolError } from "./responses.js";
import { isLocalOperationTimeout, runSequenceAction } from "./sequence.js";
import { applyStealthProfile, defaultHeadlessMode, describeError, getProxySecrets, getProxyServer, redactUrl, sanitizeErrorMessage, selectOperatingSystem } from "./utils.js";
let reservedSessions = 0;
const sessions = new Map();
export function activeSessionCount() { return sessions.size; }
export function sessionExpiresAt(session) {
return new Date(session.expiresAt).toISOString();
}
export function resetSessionTtl(session) {
clearTimeout(session.timer);
session.expiresAt = Date.now() + SESSION_TTL_MS;
session.timer = setTimeout(() => {
void closeSession(session.id, "expired");
}, SESSION_TTL_MS);
}
export function reserveSessionSlot() {
if (reservedSessions >= MAX_SESSIONS) {
return false;
}
reservedSessions += 1;
return true;
}
export function releaseSessionSlot() {
reservedSessions = Math.max(0, reservedSessions - 1);
}
export async function closeSessionNow(session, reason) {
if (session.closed) {
return false;
}
session.closing = true;
session.closed = true;
sessions.delete(session.id);
clearTimeout(session.timer);
console.error(chalk.blue(`[Camoufox] Closing session ${session.id} (${reason}).`));
try {
await closeBrowser(session.browser);
}
finally {
session.releaseSlot();
releaseSessionSlot();
}
return true;
}
export async function closeSession(sessionId, reason) {
const session = sessions.get(sessionId);
if (!session) {
return false;
}
session.closing = true;
sessions.delete(sessionId);
clearTimeout(session.timer);
await waitForSessionOperationCloseGrace(session);
return closeSessionNow(session, reason);
}
export async function waitForSessionOperationCloseGrace(session) {
let timer;
try {
await Promise.race([
session.op.catch(() => undefined),
new Promise((resolve) => {
timer = setTimeout(resolve, SESSION_CLOSE_GRACE_MS);
}),
]);
}
finally {
if (timer) {
clearTimeout(timer);
}
}
}
export async function closeActiveSessions() {
const ids = Array.from(sessions.keys());
await Promise.all(ids.map((id) => closeSession(id, "shutdown")));
}
export async function getSession(sessionId) {
const session = sessions.get(sessionId);
if (!session) {
throw new Error(`Unknown or closed session: ${sessionId}`);
}
if (Date.now() > session.expiresAt) {
await closeSession(sessionId, "expired");
throw new Error(`Session expired: ${sessionId}`);
}
resetSessionTtl(session);
return session;
}
export async function runSessionExclusive(session, operation) {
const run = session.op.catch(() => undefined).then(async () => {
if (session.closing || session.closed) {
throw new Error(`Session is closing or closed: ${session.id}`);
}
try {
return await operation();
}
catch (error) {
if (isLocalOperationTimeout(error)) {
await closeSessionNow(session, "operation-timeout");
}
throw error;
}
});
session.op = run.then(() => undefined, () => undefined);
return run;
}
export async function navigateSession(session, url, waitStrategy, timeout) {
const safeUrl = redactUrl(url);
const targetUrl = await validateTargetUrl(url);
session.rawUrls.push(url);
try {
const response = await session.page.goto(targetUrl.toString(), {
waitUntil: waitStrategy ?? session.waitStrategy,
timeout: timeout ?? DEFAULT_ACTION_TIMEOUT_MS * 6,
});
session.lastNavigationResponse = response;
await settleAndAssertSafe(session.page, session.requestGuard);
return response;
}
catch (navigationError) {
const navigationErrorMessage = describeError(navigationError).toLowerCase();
if (/\b(?:127\.0\.0\.1|localhost|ip6-localhost|ip6-loopback|::1)\b/.test(navigationErrorMessage)) {
throw new Error(`Blocked unsafe browser request to ${safeUrl}.`, { cause: navigationError });
}
session.requestGuard.assertAllowed();
throw navigationError;
}
}
export async function handleSessionStart(input) {
const effectiveInput = applyStealthProfile({
...input,
captchaPolicy: input.captchaPolicy ?? "pause",
});
if (!reserveSessionSlot()) {
return buildToolError(`Too many active sessions. Maximum is ${MAX_SESSIONS}.`);
}
let release;
let browser;
try {
await validateBrowserOptionsInput(effectiveInput);
release = await acquireBrowserSlot();
const selectedOS = selectOperatingSystem(effectiveInput.os);
const waitStrategy = effectiveInput.waitStrategy ?? "load";
const headlessMode = defaultHeadlessMode(effectiveInput.headless);
browser = await launchCamoufoxBrowser(buildCamoufoxOptions(effectiveInput, selectedOS, headlessMode));
trackBrowser(browser);
const context = await browser.newContext(browserContextOptions(effectiveInput));
const requestGuard = await installRequestGuard(context);
const page = await context.newPage();
requestGuard.watchPage(page);
const id = `sess_${randomUUID()}`;
const rawUrls = [getProxyServer(effectiveInput.proxy)].filter((rawUrl) => Boolean(rawUrl));
const secrets = getProxySecrets(effectiveInput.proxy);
const now = Date.now();
const session = {
id,
browser,
context,
page,
requestGuard,
diagnostics: createDiagnosticsCollector(page, effectiveInput, rawUrls, secrets),
selectedOS,
waitStrategy,
releaseSlot: release,
rawUrls,
secrets,
createdAt: now,
expiresAt: now + SESSION_TTL_MS,
timer: setTimeout(() => {
void closeSession(id, "expired");
}, SESSION_TTL_MS),
lastNavigationResponse: null,
op: Promise.resolve(),
closing: false,
closed: false,
};
sessions.set(id, session);
browser = undefined;
release = undefined;
return buildSuccessContent({
sessionId: id,
expiresAt: sessionExpiresAt(session),
browser: "camoufox",
selectedOS,
headlessMode,
stealthProfile: effectiveInput.stealthProfile,
captchaPolicy: effectiveInput.captchaPolicy ?? "pause",
});
}
catch (error) {
if (browser) {
await closeBrowser(browser);
}
if (release) {
release();
}
releaseSessionSlot();
const errorMessage = sanitizeErrorMessage(describeError(error), [getProxyServer(effectiveInput.proxy)].filter((rawUrl) => Boolean(rawUrl)), getProxySecrets(effectiveInput.proxy));
return buildToolError(`Failed to start browser session. Error: ${errorMessage}`);
}
}
export function sessionSanitizedError(error, session, extraRawUrls = []) {
const rawUrls = session ? [...session.rawUrls, ...extraRawUrls] : extraRawUrls;
const secrets = session?.secrets ?? [];
return sanitizeErrorMessage(describeError(error), rawUrls, secrets);
}
export async function buildSessionSnapshotResult(session, input) {
const snapshot = await runGuardedPageRead(session.page, session.requestGuard, () => buildSnapshotPayload(session.page, session.lastNavigationResponse, input.maxChars ?? DEFAULT_MAX_CHARS, input.maxElements ?? DEFAULT_MAX_ELEMENTS, input.selector));
const basePayload = { sessionId: session.id, expiresAt: sessionExpiresAt(session), ...snapshot };
if (input.captchaPolicy) {
const { mergedPayload, captchaScreenshot } = await maybeDetectCaptcha(session.page, session.lastNavigationResponse, basePayload, input.captchaPolicy, redactUrl(session.page.url()));
return buildSuccessContent(mergedPayload, captchaScreenshot);
}
return buildSuccessContent(basePayload);
}
export async function handleSessionNavigate(input) {
let session;
try {
const currentSession = await getSession(input.sessionId);
session = currentSession;
return await runSessionExclusive(currentSession, async () => {
const response = await navigateSession(currentSession, input.url, input.waitStrategy, input.timeout);
const mode = input.outputMode ?? "text";
const charLimit = input.maxChars ?? DEFAULT_MAX_CHARS;
const payload = await runGuardedPageRead(currentSession.page, currentSession.requestGuard, () => buildBrowsePayload(currentSession.page, response, mode, charLimit, input.selector));
const basePayload = { sessionId: currentSession.id, expiresAt: sessionExpiresAt(currentSession), ...payload };
if (input.captchaPolicy) {
const { mergedPayload, captchaScreenshot } = await maybeDetectCaptcha(currentSession.page, response, basePayload, input.captchaPolicy, redactUrl(input.url));
return buildSuccessContent(mergedPayload, captchaScreenshot);
}
return buildSuccessContent(basePayload);
});
}
catch (error) {
return buildToolError(`Failed to navigate session. Error: ${sessionSanitizedError(error, session, [input.url])}`);
}
}
export async function handleSessionAction(input) {
let session;
try {
const currentSession = await getSession(input.sessionId);
session = currentSession;
return await runSessionExclusive(currentSession, async () => {
const actionResult = await runSequenceAction(currentSession.page, input.action, 0, currentSession.rawUrls, currentSession.secrets);
await settleAndAssertSafe(currentSession.page, currentSession.requestGuard);
const snapshot = await runGuardedPageRead(currentSession.page, currentSession.requestGuard, () => buildSnapshotPayload(currentSession.page, currentSession.lastNavigationResponse, input.maxChars ?? DEFAULT_MAX_CHARS, input.maxElements ?? DEFAULT_MAX_ELEMENTS, input.selector));
const basePayload = { sessionId: currentSession.id, expiresAt: sessionExpiresAt(currentSession), action: actionResult, snapshot };
if (input.captchaPolicy) {
const { mergedPayload, captchaScreenshot } = await maybeDetectCaptcha(currentSession.page, currentSession.lastNavigationResponse, basePayload, input.captchaPolicy, redactUrl(currentSession.page.url()));
return buildSuccessContent(mergedPayload, captchaScreenshot);
}
return buildSuccessContent(basePayload);
});
}
catch (error) {
return buildToolError(`Failed to run session action. Error: ${sessionSanitizedError(error, session)}`);
}
}
export async function handleSessionSnapshot(input) {
let session;
try {
const currentSession = await getSession(input.sessionId);
session = currentSession;
return await runSessionExclusive(currentSession, async () => buildSessionSnapshotResult(currentSession, input));
}
catch (error) {
return buildToolError(`Failed to snapshot session. Error: ${sessionSanitizedError(error, session)}`);
}
}
export async function handleSessionResume(input) {
let session;
try {
const currentSession = await getSession(input.sessionId);
session = currentSession;
return await runSessionExclusive(currentSession, async () => {
if (input.waitStrategy) {
await currentSession.page.waitForLoadState(input.waitStrategy, { timeout: input.timeout ?? DEFAULT_ACTION_TIMEOUT_MS });
await settleAndAssertSafe(currentSession.page, currentSession.requestGuard);
}
return buildSessionSnapshotResult(currentSession, input);
});
}
catch (error) {
return buildToolError(`Failed to resume session. Error: ${sessionSanitizedError(error, session)}`);
}
}
export async function handleSessionClose(input) {
const closed = await closeSession(input.sessionId, "requested");
return buildSuccessContent({
sessionId: input.sessionId,
closed,
});
}