# Browser MCP Pattern (Playwright + Browserless) A fully headless browser MCP server that provides web navigation, screenshots, JavaScript execution, element extraction, and click interactions. Uses **browserless/chrome** as the rendering engine and **Playwright** for the MCP integration. ## Architecture ``` Open WebUI / Hermes │ MCP (Streamable HTTP) ↓ mcp-browser (Python 3.13, FastMCP, Playwright) │ CDP WebSocket ↓ browserless/chrome (headless Chromium) ``` ## Docker Compose (add to existing LiteLLM stack) ```yaml services: browserless: image: browserless/chrome:latest container_name: browserless restart: unless-stopped environment: - CONNECTION_TIMEOUT=600000 - MAX_CONCURRENT_SESSIONS=5 - PREBOOT_CHROME=true ports: - '127.0.0.1:3005:3000' # 3000 conflicts with Open WebUI host mapping networks: - litellm-net deploy: resources: limits: {cpus: '2', memory: '1G'} reservations: {cpus: '0.5', memory: '256M'} mcp-browser: build: ../mcp-browser container_name: mcp-browser restart: unless-stopped environment: - BROWSER_WS=ws://browserless:3000 # Docker internal network name - PYTHONUNBUFFERED=1 ports: - '127.0.0.1:8901:8901' networks: - litellm-net depends_on: - browserless ``` ## Dockerfile ```dockerfile FROM python:3.13-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ curl ca-certificates && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt RUN python3 -m playwright install chromium RUN python3 -m playwright install-deps chromium COPY server.py ./ ENV PYTHONUNBUFFERED=1 EXPOSE 8901 CMD ["python3", "server.py"] ``` ## Requirements ``` fastmcp>=3.0.0 playwright>=1.45.0 httpx>=0.27.0 ``` ## Server Template (server.py) ```python from fastmcp import FastMCP from playwright.async_api import async_playwright import base64, os BROWSER_WS = os.getenv('BROWSER_WS', 'ws://browserless:3000') mcp = FastMCP('Browser', version='1.0.0') _browser = None async def get_browser(): global _browser if _browser is None: pw = await async_playwright().start() _browser = await pw.chromium.connect_over_cdp(BROWSER_WS) return _browser @mcp.tool(description="Navigate to a URL and return page text + title") async def browse(url: str) -> str: browser = await get_browser() page = await browser.new_page() try: await page.goto(url, wait_until='networkidle', timeout=30000) title = await page.title() text = await page.evaluate('document.body?.innerText || ""') return f"Title: {title}\n\n{text[:10000]}" finally: await page.close() @mcp.tool(description="Take a full-page screenshot, return as base64 PNG") async def screenshot(url: str) -> str: browser = await get_browser() page = await browser.new_page() try: await page.goto(url, wait_until='networkidle', timeout=30000) data = await page.screenshot(full_page=True, type='png') return f"data:image/png;base64,{base64.b64encode(data).decode()}" finally: await page.close() @mcp.tool(description="Execute JavaScript on a page, return result") async def execute_js(url: str, code: str) -> str: browser = await get_browser() page = await browser.new_page() try: await page.goto(url, wait_until='networkidle', timeout=30000) result = await page.evaluate(code) return str(result)[:5000] finally: await page.close() @mcp.tool(description="Extract content by CSS selector") async def extract_element(url: str, selector: str) -> str: browser = await get_browser() page = await browser.new_page() try: await page.goto(url, wait_until='networkidle', timeout=30000) elements = await page.query_selector_all(selector) texts = [await el.inner_text() for el in elements if (await el.inner_text()).strip()] return f"Found {len(texts)} elements matching '{selector}':\n" + "\n---\n".join(texts[:10]) finally: await page.close() @mcp.tool(description="Click an element by text or CSS selector, return new page state") async def click(url: str, target: str, is_selector: bool = False) -> str: browser = await get_browser() page = await browser.new_page() try: await page.goto(url, wait_until='networkidle', timeout=30000) if is_selector: await page.click(target) else: await page.click(f'text={target}') await page.wait_for_load_state('networkidle', timeout=10000) title = await page.title() text = await page.evaluate('document.body?.innerText || ""') return f"Title: {title}\nURL: {page.url}\n\n{text[:8000]}" finally: await page.close() if __name__ == '__main__': mcp.run(transport='http', host='0.0.0.0', port=8901, path='/mcp') ``` ## Verifying ```bash docker exec mcp-browser python3 -c " from fastmcp import Client, asyncio async def test(): async with Client('http://127.0.0.1:8901/mcp') as client: tools = await client.list_tools() print(f'Tools: {len(tools)}') for t in tools: print(f' - {t.name}: {t.description[:50]}...') asyncio.run(test()) " ``` ## Pitfalls - **Port 3000 conflict** — Open WebUI maps host port 3000. Browserless also defaults to 3000. Use `127.0.0.1:3005:3000` to remap. - **Playwright deps install is slow** — `playwright install-deps chromium` pulls apt packages, 2-3 min build time. Cache the Docker layer. - **Browserless TOKEN env** — Set `TOKEN=` (empty) to disable token auth. The default requires no auth for internal networks. - **Slim images lack curl** — The Dockerfile adds `curl ca-certificates` explicitly. Health checks using `curl` still won't work in slim containers without this. - **CDP connection reuse** — The server reuses one browser instance across all requests via `connect_over_cdp`. Each tool opens/closes a page (tab), not a whole browser.