dev-mcps/browser-mcp/src/browser-service.js

182 lines
7.6 KiB
JavaScript

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(() => {});
}
}