Initial skills documentation — 25 categories, all SKILL.md + references + scripts
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user