import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { chromium, type Browser, type Page } from "playwright-chromium";
import { compressScreenshot, imageContent } from "../utils/image.js";
let browser: Browser | null = null;
let page: Page | null = null;
async function ensureBrowser(headless: boolean = false): Promise<Page> {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({ headless });
const context = await browser.newContext({ viewport: null });
page = await context.newPage();
}
return page!;
}
export function registerBrowserTools(server: McpServer) {
// browser_open
server.tool(
"browser_open",
"Launch Chromium browser and navigate to a URL",
{
url: z.string().url().describe("URL to open"),
headless: z.boolean().optional().describe("Run headless (default: false, visible)"),
},
async ({ url, headless }) => {
try {
// Close existing if any
if (browser && browser.isConnected()) {
await browser.close().catch(() => {});
browser = null;
page = null;
}
const p = await ensureBrowser(headless ?? false);
await p.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
const title = await p.title();
return { content: [{ type: "text", text: `Opened ${url}\nTitle: ${title}` }] };
} catch (err: any) {
return { content: [{ type: "text", text: `browser_open failed: ${err.message}` }], isError: true };
}
}
);
// browser_navigate
server.tool(
"browser_navigate",
"Navigate the current browser page to a URL",
{
url: z.string().url().describe("URL to navigate to"),
waitUntil: z.enum(["load", "domcontentloaded", "networkidle", "commit"]).optional().describe("Wait condition (default: domcontentloaded)"),
},
async ({ url, waitUntil }) => {
try {
if (!page) return { content: [{ type: "text", text: "No browser open. Use browser_open first." }], isError: true };
await page.goto(url, { waitUntil: waitUntil ?? "domcontentloaded", timeout: 30_000 });
const title = await page.title();
return { content: [{ type: "text", text: `Navigated to ${url}\nTitle: ${title}` }] };
} catch (err: any) {
return { content: [{ type: "text", text: `browser_navigate failed: ${err.message}` }], isError: true };
}
}
);
// browser_click
server.tool(
"browser_click",
"Click an element on the page by CSS selector",
{
selector: z.string().describe("CSS selector of element to click"),
button: z.enum(["left", "right", "middle"]).optional().describe("Mouse button (default: left)"),
clickCount: z.number().optional().describe("Number of clicks (default: 1)"),
},
async ({ selector, button, clickCount }) => {
try {
if (!page) return { content: [{ type: "text", text: "No browser open. Use browser_open first." }], isError: true };
await page.click(selector, {
button: button ?? "left",
clickCount: clickCount ?? 1,
timeout: 10_000,
});
return { content: [{ type: "text", text: `Clicked: ${selector}` }] };
} catch (err: any) {
return { content: [{ type: "text", text: `browser_click failed: ${err.message}` }], isError: true };
}
}
);
// browser_type
server.tool(
"browser_type",
"Type text into an input element on the page",
{
selector: z.string().describe("CSS selector of input element"),
text: z.string().describe("Text to type"),
clear: z.boolean().optional().describe("Clear existing content first (default: false)"),
pressEnter: z.boolean().optional().describe("Press Enter after typing (default: false)"),
},
async ({ selector, text, clear, pressEnter }) => {
try {
if (!page) return { content: [{ type: "text", text: "No browser open. Use browser_open first." }], isError: true };
if (clear) {
await page.fill(selector, "");
}
await page.type(selector, text, { timeout: 10_000 });
if (pressEnter) {
await page.press(selector, "Enter");
}
return { content: [{ type: "text", text: `Typed into ${selector}: "${text}"${pressEnter ? " + Enter" : ""}` }] };
} catch (err: any) {
return { content: [{ type: "text", text: `browser_type failed: ${err.message}` }], isError: true };
}
}
);
// browser_read
server.tool(
"browser_read",
"Read content from the current page",
{
mode: z.enum(["text", "html", "title", "url", "element"]).describe("What to read: text (visible text), html (innerHTML), title, url, or element (specific element text)"),
selector: z.string().optional().describe("CSS selector (required for mode 'element', optional for 'text'/'html' to scope)"),
},
async ({ mode, selector }) => {
try {
if (!page) return { content: [{ type: "text", text: "No browser open. Use browser_open first." }], isError: true };
let result: string;
switch (mode) {
case "title":
result = await page.title();
break;
case "url":
result = page.url();
break;
case "text":
if (selector) {
result = await page.locator(selector).innerText({ timeout: 10_000 });
} else {
result = await page.locator("body").innerText({ timeout: 10_000 });
}
break;
case "html":
if (selector) {
result = await page.locator(selector).innerHTML({ timeout: 10_000 });
} else {
result = await page.content();
}
break;
case "element":
if (!selector) return { content: [{ type: "text", text: "selector is required for mode 'element'" }], isError: true };
result = await page.locator(selector).innerText({ timeout: 10_000 });
break;
default:
result = "";
}
// Truncate very long content
if (result.length > 50_000) {
result = result.slice(0, 50_000) + "\n... (truncated)";
}
return { content: [{ type: "text", text: result }] };
} catch (err: any) {
return { content: [{ type: "text", text: `browser_read failed: ${err.message}` }], isError: true };
}
}
);
// browser_screenshot
server.tool(
"browser_screenshot",
"Take a screenshot of the current browser page",
{
fullPage: z.boolean().optional().describe("Capture full scrollable page (default: false, viewport only)"),
selector: z.string().optional().describe("CSS selector to screenshot a specific element"),
},
async ({ fullPage, selector }) => {
try {
if (!page) return { content: [{ type: "text", text: "No browser open. Use browser_open first." }], isError: true };
let buf: Buffer;
if (selector) {
buf = await page.locator(selector).screenshot({ type: "png", timeout: 10_000 });
} else {
buf = await page.screenshot({ type: "png", fullPage: fullPage ?? false, timeout: 10_000 });
}
const base64 = await compressScreenshot(buf);
return imageContent(base64);
} catch (err: any) {
return { content: [{ type: "text", text: `browser_screenshot failed: ${err.message}` }], isError: true };
}
}
);
// browser_close
server.tool(
"browser_close",
"Close the browser instance",
{},
async () => {
try {
if (browser && browser.isConnected()) {
await browser.close();
}
browser = null;
page = null;
return { content: [{ type: "text", text: "Browser closed." }] };
} catch (err: any) {
return { content: [{ type: "text", text: `browser_close failed: ${err.message}` }], isError: true };
}
}
);
}