import { BrowserAccessPolicy } from "./access-policy.js"; function boundedString(value, label, maxLength) { if (typeof value !== "string" || value.length === 0 || value.length > maxLength || value.includes("\0")) throw new Error(`${label} must contain between 1 and ${maxLength} characters`); return value; } function boundedInteger(value, fallback, minimum, maximum, label) { const result = value ?? fallback; if (!Number.isInteger(result) || result < minimum || result > maximum) throw new Error(`${label} must be between ${minimum} and ${maximum}`); return result; } function pushBounded(array, value, maximum = 200) { array.push(value); if (array.length > maximum) array.splice(0, array.length - maximum); } export class BrowserService { constructor({ allowedOrigins, browserFactory, maxSessions = 16, defaultTimeoutMs = 15000 }) { this.policy = new BrowserAccessPolicy(allowedOrigins); this.browserFactory = browserFactory; this.maxSessions = maxSessions; this.defaultTimeoutMs = defaultTimeoutMs; this.sessions = new Map(); this.browserPromise = null; } async browser() { if (!this.browserPromise) this.browserPromise = this.browserFactory(); return this.browserPromise; } async createSession(sessionId) { if (this.sessions.has(sessionId)) return this.sessions.get(sessionId); if (this.sessions.size >= this.maxSessions) throw new Error("Browser session capacity reached"); const browser = await this.browser(); const context = await browser.newContext({ acceptDownloads: false, ignoreHTTPSErrors: false, serviceWorkers: "block", viewport: { width: 1440, height: 900 } }); await context.route("**/*", async (route) => { try { this.policy.assertUrl(route.request().url()); await route.continue(); } catch { await route.abort("blockedbyclient"); } }); await context.routeWebSocket("**/*", async (webSocket) => { try { this.policy.assertWebSocketUrl(webSocket.url()); webSocket.connectToServer(); } catch { await webSocket.close({ code: 1008, reason: "Origin is not allowed" }); } }); const page = await context.newPage(); page.setDefaultTimeout(this.defaultTimeoutMs); page.setDefaultNavigationTimeout(this.defaultTimeoutMs); const state = { context, page, console: [], network: [], queue: Promise.resolve() }; page.on("console", (message) => pushBounded(state.console, { type: message.type(), text: message.text().slice(0, 4096), timestamp: new Date().toISOString() })); page.on("pageerror", (error) => pushBounded(state.console, { type: "pageerror", text: error.message.slice(0, 4096), timestamp: new Date().toISOString() })); page.on("requestfailed", (request) => pushBounded(state.network, { type: "requestfailed", method: request.method(), url: request.url().slice(0, 4096), error: request.failure()?.errorText ?? "failed" })); page.on("response", (response) => { if (response.status() >= 400) pushBounded(state.network, { type: "http", status: response.status(), url: response.url().slice(0, 4096) }); }); this.sessions.set(sessionId, state); return state; } async run(sessionId, operation) { if (typeof sessionId !== "string" || !sessionId) throw new Error("An initialized MCP session is required"); const state = await this.createSession(sessionId); const next = state.queue.then(() => operation(state)); state.queue = next.catch(() => {}); return next; } navigate(args, context) { return this.run(context.sessionId, async ({ page }) => { const url = this.policy.assertUrl(boundedString(args.url, "url", 4096), { navigation: true }); const timeout = boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs"); await page.goto(url, { waitUntil: "domcontentloaded", timeout }); return { url: page.url(), title: await page.title() }; }); } click(args, context) { return this.run(context.sessionId, async ({ page }) => { const selector = boundedString(args.selector, "selector", 1024); await page.locator(selector).first().click({ timeout: boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs") }); return { clicked: selector, url: page.url() }; }); } fill(args, context) { return this.run(context.sessionId, async ({ page }) => { const selector = boundedString(args.selector, "selector", 1024); const value = boundedString(args.value, "value", 10000); await page.locator(selector).first().fill(value, { timeout: boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs") }); return { filled: selector, characters: value.length }; }); } wait(args, context) { return this.run(context.sessionId, async ({ page }) => { const milliseconds = boundedInteger(args.milliseconds, 500, 1, 5000, "milliseconds"); await page.waitForTimeout(milliseconds); return { waitedMilliseconds: milliseconds }; }); } snapshot(args, context) { return this.run(context.sessionId, async ({ page }) => { const maxTextCharacters = boundedInteger(args.maxTextCharacters, 50000, 1000, 100000, "maxTextCharacters"); const snapshot = await page.evaluate(({ textLimit }) => { const text = (document.body?.innerText ?? "").slice(0, textLimit); const elements = [...document.querySelectorAll("a,button,input,select,textarea,[role]")].slice(0, 200).map((element, index) => ({ index, tag: element.tagName.toLowerCase(), role: element.getAttribute("role"), name: (element.getAttribute("aria-label") || element.textContent || element.getAttribute("placeholder") || "").trim().slice(0, 300), type: element.getAttribute("type"), href: element instanceof HTMLAnchorElement ? element.href : null, disabled: "disabled" in element ? Boolean(element.disabled) : false })); return { text, textTruncated: (document.body?.innerText?.length ?? 0) > textLimit, elements }; }, { textLimit: maxTextCharacters }); return { url: page.url(), title: await page.title(), ...snapshot }; }); } screenshot(args, context) { return this.run(context.sessionId, async ({ page }) => { const fullPage = args.fullPage === true; const data = await page.screenshot({ type: "png", fullPage, animations: "disabled", caret: "hide" }); if (data.length > 8 * 1024 * 1024) throw new Error("Screenshot exceeds the 8 MiB response limit"); return { content: [ { type: "image", data: data.toString("base64"), mimeType: "image/png" }, { type: "text", text: JSON.stringify({ url: page.url(), fullPage, bytes: data.length }) } ], structuredContent: { url: page.url(), fullPage, bytes: data.length }, isError: false }; }); } consoleMessages(args, context) { return this.run(context.sessionId, async (state) => { const messages = [...state.console]; if (args.clear === true) state.console.length = 0; return { messages }; }); } networkErrors(args, context) { return this.run(context.sessionId, async (state) => { const errors = [...state.network]; if (args.clear === true) state.network.length = 0; return { errors }; }); } async closeSession(sessionId) { const state = this.sessions.get(sessionId); if (!state) return; this.sessions.delete(sessionId); await state.context.close().catch(() => {}); } async close() { await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id))); if (this.browserPromise) await (await this.browserPromise).close().catch(() => {}); } }