Files
zeekay 303a05a616 feat(agent): agentic proxy with agent integration
Enhanced agent tool architecture:
- 8 CLI agents: claude, codex, gemini, grok, qwen, vibe, code, dev
- Auto-detects agent environment for seamless auth
- Shares hanzo-mcp config with spawned agents
- Lightweight - no heavy litellm dependency

Essential tools reorganization:
- Added agent, think, critic to essential tools
- Removed llm/consensus from essential (heavy deps, opt-in)
- HEAVY_TOOLS constant for tracking opt-in tools

Config sharing:
- HANZO_AGENT_PARENT=true passed to child agents
- MCP config env vars propagated
- API keys shared when appropriate
2025-12-26 12:25:16 -08:00

1689 lines
72 KiB
Python

"""High-performance async browser automation tool using Playwright.
Design goals:
- Async-first: All operations are non-blocking
- Shared browser instance: Reuse browser across calls (low latency)
- Connection pooling: Multiple pages/contexts for parallel work
- Cross-MCP sharing: Connect to existing browser via CDP endpoint
- Full Playwright API: Complete surface area coverage
- Touch support: Mobile device emulation with touch events
- Network control: Intercept, mock, and monitor requests
PARALLEL AGENTS ARCHITECTURE:
- BrowserPool is a singleton - one Chrome process per MCP server
- Each agent can use `new_context` to get an isolated browser context
- Contexts have separate: cookies, localStorage, sessionStorage, cache
- Tabs within same context share state (use for same-session workflows)
- For true multi-process sharing, launch Chrome with CDP and connect:
BROWSER_CDP_ENDPOINT=http://localhost:9222 hanzo-mcp
"""
import os
import re
import json
import base64
import asyncio
import logging
from typing import Any, Union, Literal, ClassVar, Optional, Annotated
from pathlib import Path
from dataclasses import field, dataclass
from pydantic import Field
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool
# Playwright import with graceful fallback
try:
from playwright.async_api import (
Page,
Route,
Dialog,
Browser,
Locator,
Request,
Download,
Response,
Playwright,
BrowserContext,
ConsoleMessage,
async_playwright,
)
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
Browser = Page = BrowserContext = Playwright = None
Route = Request = Response = Dialog = ConsoleMessage = Download = Locator = None
logger = logging.getLogger(__name__)
# Device presets - user-friendly aliases + specific devices
DEVICES = {
# User-friendly aliases
"mobile": {
"viewport": {"width": 390, "height": 844},
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
"device_scale_factor": 3,
"is_mobile": True,
"has_touch": True,
},
"tablet": {
"viewport": {"width": 1024, "height": 1366},
"user_agent": "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
"device_scale_factor": 2,
"is_mobile": True,
"has_touch": True,
},
"laptop": {
"viewport": {"width": 1440, "height": 900},
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"device_scale_factor": 2,
"is_mobile": False,
"has_touch": False,
},
"desktop": { # Alias for laptop
"viewport": {"width": 1920, "height": 1080},
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"device_scale_factor": 1,
"is_mobile": False,
"has_touch": False,
},
# Specific devices
"iphone_14": {
"viewport": {"width": 390, "height": 844},
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
"device_scale_factor": 3,
"is_mobile": True,
"has_touch": True,
},
"iphone_15_pro": {
"viewport": {"width": 393, "height": 852},
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
"device_scale_factor": 3,
"is_mobile": True,
"has_touch": True,
},
"pixel_7": {
"viewport": {"width": 412, "height": 915},
"user_agent": "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
"device_scale_factor": 2.625,
"is_mobile": True,
"has_touch": True,
},
"ipad_pro": {
"viewport": {"width": 1024, "height": 1366},
"user_agent": "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
"device_scale_factor": 2,
"is_mobile": True,
"has_touch": True,
},
"galaxy_s23": {
"viewport": {"width": 360, "height": 780},
"user_agent": "Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
"device_scale_factor": 3,
"is_mobile": True,
"has_touch": True,
},
}
Action = Annotated[
Literal[
# === Core Page Navigation & Lifecycle ===
"navigate", # goto(url)
"set_content", # setContent(html)
"content", # content() - get full HTML
"url", # url() - get current URL
"title", # title() - get page title
"reload", # reload()
"go_back", # goBack()
"go_forward", # goForward()
"close", # close page/browser
# === Input - Click/Type ===
"click", # click(selector)
"dblclick", # dblclick(selector)
"type", # type(selector, text) - character by character
"fill", # fill(selector, text) - instant, clears first
"clear", # clear input
"press", # press key combo (Ctrl+A, Enter, etc.)
# === Input - Forms ===
"select_option", # select dropdown option
"check", # check checkbox/radio
"uncheck", # uncheck checkbox
"upload", # set_input_files
# === Mouse ===
"hover", # hover(selector)
"drag", # drag_and_drop(source, target)
"mouse_move", # mouse.move(x, y)
"mouse_down", # mouse.down()
"mouse_up", # mouse.up()
"mouse_wheel", # mouse.wheel(dx, dy)
"scroll", # scroll element into view or scroll by delta
# === Touch (Mobile) ===
"tap", # tap(selector) - touch tap
"swipe", # swipe gesture
"pinch", # pinch zoom
# === Locator Creation ===
"locator", # Create locator (CSS, text, role, xpath)
"frame_locator", # frameLocator(selector)
# === Built-in Locators (get_by_*) ===
"get_by_role", # getByRole(role, {name})
"get_by_text", # getByText(text)
"get_by_label", # getByLabel(text)
"get_by_placeholder", # getByPlaceholder(text)
"get_by_test_id", # getByTestId(id)
"get_by_alt_text", # getByAltText(text)
"get_by_title", # getByTitle(text)
# === Locator Composition ===
"first", # locator.first
"last", # locator.last
"nth", # locator.nth(index)
"filter", # locator.filter({has, hasText, hasNotText})
"all", # locator.all() - get all matching
"count", # locator.count()
# === Content Extraction ===
"get_text", # textContent()
"get_inner_text", # innerText()
"get_attribute", # getAttribute(name)
"get_value", # inputValue()
"get_html", # innerHTML() or content()
"get_bounding_box", # boundingBox()
# === State Checks ===
"is_visible", # isVisible()
"is_enabled", # isEnabled()
"is_checked", # isChecked()
"is_hidden", # isHidden()
"is_editable", # isEditable()
# === Assertions (expect) ===
"expect_visible", # expect(loc).toBeVisible()
"expect_hidden", # expect(loc).toBeHidden()
"expect_enabled", # expect(loc).toBeEnabled()
"expect_text", # expect(loc).toHaveText()
"expect_value", # expect(loc).toHaveValue()
"expect_checked", # expect(loc).toBeChecked()
"expect_url", # expect(page).toHaveURL()
"expect_title", # expect(page).toHaveTitle()
"expect_count", # expect(loc).toHaveCount()
"expect_attribute", # expect(loc).toHaveAttribute()
# === Page Actions ===
"screenshot", # screenshot()
"pdf", # pdf()
"snapshot", # accessibility.snapshot()
"evaluate", # evaluate(js)
"focus", # focus(selector)
"blur", # blur()
# === Wait Primitives ===
"wait", # waitForSelector or sleep
"wait_for_load", # waitForLoadState(networkidle, etc)
"wait_for_url", # waitForURL(pattern)
"wait_for_event", # waitForEvent(event) - request, response, download, filechooser, popup
"wait_for_request", # waitForRequest(pattern)
"wait_for_response", # waitForResponse(pattern)
"wait_for_function", # waitForFunction(js)
# === Viewport & Device ===
"viewport", # setViewportSize
"emulate", # emulate device (mobile, tablet, laptop)
"geolocation", # setGeolocation
"permissions", # grantPermissions
# === Network Interception ===
"route", # route(pattern, handler) - mock/block
"unroute", # unroute(pattern)
# === Storage & Cookies ===
"cookies", # cookies() or addCookies()
"clear_cookies", # clearCookies()
"storage", # localStorage/sessionStorage
"storage_state", # storageState() - save/load auth
# === Events & Handlers ===
"on", # page.on(event, handler)
"off", # removeListener
# === Dialogs ===
"dialog", # handle pending dialog
# === Frames ===
"frame", # switch to frame
"main_frame", # back to main frame
# === File Chooser & Downloads ===
"file_chooser", # waitForEvent('filechooser')
"download", # waitForEvent('download')
# === Console & Errors ===
"console", # get console messages
"errors", # get page errors
# === Browser/Context Management ===
"new_page", # context.newPage()
"new_context", # browser.newContext() - isolated session
"new_tab", # alias for new_page
"close_tab", # close current page
"tabs", # list/switch tabs
"connect", # connect via CDP
"set_headless", # toggle headless/headed
"status", # get browser status
# === Debug/Tracing ===
"trace_start", # tracing.start()
"trace_stop", # tracing.stop()
"highlight", # highlight element for debugging
],
Field(description="Browser action to perform"),
]
@dataclass
class BrowserState:
"""Track browser state for debugging and monitoring."""
console_messages: list[dict] = field(default_factory=list)
page_errors: list[str] = field(default_factory=list)
routes: dict[str, dict] = field(default_factory=dict)
event_handlers: dict[str, list] = field(default_factory=dict)
tracing: bool = False
pending_dialog: Any = None
pending_download: Any = None
pending_file_chooser: Any = None
class BrowserPool:
"""Shared browser instance pool for high-performance automation.
ARCHITECTURE FOR PARALLEL AGENTS:
- Singleton per MCP process - one Chrome, many contexts
- new_context() creates isolated sessions (separate cookies/storage)
- Tabs share context state, contexts are isolated
- For multi-process sharing, use CDP endpoint
"""
_instance: ClassVar[Optional["BrowserPool"]] = None
_lock: ClassVar[asyncio.Lock] = asyncio.Lock()
def __init__(self):
self._playwright: Optional[Playwright] = None
self._browser: Optional[Browser] = None
self._context: Optional[BrowserContext] = None
self._page: Optional[Page] = None
self._pages: list[Page] = []
self._contexts: list[BrowserContext] = []
self._headless: bool = True
self._cdp_endpoint: Optional[str] = None
self._initialized: bool = False
self._state: BrowserState = BrowserState()
self._device: Optional[str] = None
@classmethod
async def get_instance(cls) -> "BrowserPool":
"""Get or create the singleton browser pool."""
async with cls._lock:
if cls._instance is None:
cls._instance = BrowserPool()
return cls._instance
@classmethod
async def shutdown(cls) -> None:
"""Shutdown the browser pool."""
async with cls._lock:
if cls._instance is not None:
await cls._instance.close()
cls._instance = None
def _setup_page_listeners(self, page: Page) -> None:
"""Set up event listeners for a page."""
# Console messages
page.on(
"console",
lambda msg: self._state.console_messages.append(
{
"type": msg.type,
"text": msg.text,
"location": getattr(msg, "location", None),
}
),
)
# Page errors
page.on("pageerror", lambda err: self._state.page_errors.append(str(err)))
# Dialogs
async def handle_dialog(dialog: Dialog):
self._state.pending_dialog = dialog
page.on("dialog", handle_dialog)
# Downloads
def handle_download(download: Download):
self._state.pending_download = download
page.on("download", handle_download)
# File chooser
def handle_filechooser(file_chooser):
self._state.pending_file_chooser = file_chooser
page.on("filechooser", handle_filechooser)
async def ensure_browser(
self,
headless: bool = True,
cdp_endpoint: Optional[str] = None,
device: Optional[str] = None,
) -> Page:
"""Ensure browser is running, return current page."""
if not PLAYWRIGHT_AVAILABLE:
raise RuntimeError("Playwright not installed. Run: pip install playwright && playwright install chromium")
needs_init = (
not self._initialized
or self._page is None
or self._browser is None
or self._cdp_endpoint != cdp_endpoint
or self._device != device
)
if needs_init:
if self._initialized:
await self.close()
self._playwright = await async_playwright().start()
self._headless = headless
self._cdp_endpoint = cdp_endpoint
self._device = device
self._state = BrowserState()
device_settings = DEVICES.get(device) if device else None
if cdp_endpoint:
logger.info(f"Connecting to browser at {cdp_endpoint}")
self._browser = await self._playwright.chromium.connect_over_cdp(cdp_endpoint)
contexts = self._browser.contexts
if contexts:
self._context = contexts[0]
pages = self._context.pages
if pages:
self._page = pages[0]
self._pages = list(pages)
else:
self._page = await self._context.new_page()
self._pages = [self._page]
else:
context_opts = {"viewport": {"width": 1280, "height": 720}}
if device_settings:
context_opts.update(device_settings)
self._context = await self._browser.new_context(**context_opts)
self._page = await self._context.new_page()
self._pages = [self._page]
else:
self._browser = await self._playwright.chromium.launch(
headless=headless,
args=[
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
],
)
context_opts = {
"viewport": {"width": 1440, "height": 900},
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
if device_settings:
context_opts.update(device_settings)
self._context = await self._browser.new_context(**context_opts)
self._contexts = [self._context]
self._page = await self._context.new_page()
self._pages = [self._page]
self._setup_page_listeners(self._page)
self._initialized = True
logger.info(f"Browser initialized (device={device or 'laptop'})")
return self._page
async def new_context(self, device: Optional[str] = None, **kwargs) -> BrowserContext:
"""Create a new isolated browser context for parallel agents."""
if not self._browser:
raise RuntimeError("Browser not initialized")
context_opts = {}
if device and device in DEVICES:
context_opts.update(DEVICES[device])
context_opts.update(kwargs)
context = await self._browser.new_context(**context_opts)
self._contexts.append(context)
return context
async def new_page(self, url: Optional[str] = None, context: Optional[BrowserContext] = None) -> Page:
"""Open a new page in specified or current context."""
ctx = context or self._context
if not ctx:
raise RuntimeError("Browser not initialized")
page = await ctx.new_page()
self._setup_page_listeners(page)
self._pages.append(page)
self._page = page
if url:
await page.goto(url)
return page
async def close_page(self, index: Optional[int] = None) -> None:
"""Close a page by index (default: current page)."""
if not self._pages:
return
idx = index if index is not None else self._pages.index(self._page) if self._page in self._pages else -1
if 0 <= idx < len(self._pages):
page = self._pages.pop(idx)
await page.close()
if self._pages:
self._page = self._pages[min(idx, len(self._pages) - 1)]
else:
self._page = None
async def switch_page(self, index: int) -> Page:
"""Switch to page by index."""
if 0 <= index < len(self._pages):
self._page = self._pages[index]
await self._page.bring_to_front()
return self._page
raise ValueError(f"Invalid page index: {index}")
async def close(self) -> None:
"""Close browser and cleanup."""
if self._state.tracing and self._context:
try:
await self._context.tracing.stop()
except Exception:
pass
if self._browser:
try:
await self._browser.close()
except Exception as e:
logger.warning(f"Error closing browser: {e}")
if self._playwright:
try:
await self._playwright.stop()
except Exception as e:
logger.warning(f"Error stopping playwright: {e}")
self._browser = None
self._context = None
self._page = None
self._pages = []
self._contexts = []
self._playwright = None
self._initialized = False
self._state = BrowserState()
logger.info("Browser closed")
@property
def page(self) -> Optional[Page]:
return self._page
@property
def pages(self) -> list[Page]:
return self._pages
@property
def state(self) -> BrowserState:
return self._state
class BrowserTool(BaseTool):
"""Complete browser automation with full Playwright API surface area.
PARALLEL AGENTS:
Use `new_context` action to create isolated sessions.
Each context has separate cookies, storage, and cache.
One Chrome process, many parallel agent sessions.
DEVICES:
- mobile, tablet, laptop (user-friendly)
- iphone_14, iphone_15_pro, pixel_7, galaxy_s23, ipad_pro (specific)
"""
name = "browser"
def __init__(self, headless: bool = True, cdp_endpoint: Optional[str] = None):
self.headless = headless
self.cdp_endpoint = cdp_endpoint or os.environ.get("BROWSER_CDP_ENDPOINT")
self.timeout = 30000
@property
def description(self) -> str:
return """Complete browser automation with full Playwright API.
DISPLAY INSTRUCTIONS: Show results as bullet points.
• navigate: Navigated to [url] (status: [status])
• click/tap: [action] on [selector]
• expect_*: ✓ Assertion passed / ✗ Assertion failed
• screenshot: Captured [size] bytes
PARALLEL AGENTS:
- Use `new_context` for isolated sessions
- One Chrome, many parallel agent contexts
DEVICES: mobile, tablet, laptop, iphone_14, pixel_7, ipad_pro
CATEGORIES:
- Navigation: navigate, set_content, content, url, title, reload, go_back/forward
- Input: click, dblclick, type, fill, clear, press
- Forms: select_option, check, uncheck, upload
- Mouse: hover, drag, mouse_move/down/up, mouse_wheel, scroll
- Touch: tap, swipe, pinch
- Locators: locator, get_by_role/text/label/placeholder/test_id/alt_text/title
- Composition: first, last, nth, filter, all, count
- Content: get_text, get_inner_text, get_attribute, get_value, get_html, get_bounding_box
- State: is_visible/hidden/enabled/editable/checked
- Assertions: expect_visible/hidden/enabled/text/value/checked/url/title/count/attribute
- Wait: wait, wait_for_load/url/event/request/response/function
- Page: screenshot, pdf, snapshot, evaluate, focus, blur
- Device: viewport, emulate, geolocation, permissions
- Network: route (mock/block), unroute
- Storage: cookies, clear_cookies, storage, storage_state
- Events: on, off
- Dialogs: dialog
- Files: file_chooser, download
- Browser: new_page, new_context, new_tab, close_tab, tabs, status
- Debug: trace_start/stop, highlight, console, errors
"""
async def _get_page(self, device: Optional[str] = None) -> Page:
"""Get page from shared pool."""
pool = await BrowserPool.get_instance()
return await pool.ensure_browser(
headless=self.headless,
cdp_endpoint=self.cdp_endpoint,
device=device,
)
def _get_locator(self, page: Page, selector: str, frame: Optional[str] = None) -> Locator:
"""Get a locator, optionally within a frame."""
if frame:
return page.frame_locator(frame).locator(selector)
return page.locator(selector)
async def call(self, ctx, action: str, **kwargs) -> dict[str, Any]:
"""Execute browser action."""
return await self.execute(action=action, **kwargs)
async def execute(
self,
action: str,
# Selectors
url: Optional[str] = None,
selector: Optional[str] = None,
ref: Optional[str] = None,
target_selector: Optional[str] = None,
# Text/Values
text: Optional[str] = None,
value: Optional[str] = None,
key: Optional[str] = None,
code: Optional[str] = None,
html: Optional[str] = None,
attribute: Optional[str] = None,
# Locator options
role: Optional[str] = None,
name: Optional[str] = None,
exact: bool = False,
# Locator composition
index: Optional[int] = None,
has_text: Optional[str] = None,
has_not_text: Optional[str] = None,
has: Optional[str] = None, # Nested selector
# Files
files: Optional[list[str]] = None,
# Mouse/Touch
x: Optional[int] = None,
y: Optional[int] = None,
button: Optional[str] = None,
delta_x: Optional[int] = None,
delta_y: Optional[int] = None,
direction: Optional[str] = None,
distance: Optional[int] = None,
scale: Optional[float] = None,
# Viewport/Device
width: Optional[int] = None,
height: Optional[int] = None,
device: Optional[str] = None,
# Geolocation
latitude: Optional[float] = None,
longitude: Optional[float] = None,
accuracy: Optional[float] = None,
# Permissions
permission: Optional[str] = None,
# Network
pattern: Optional[str] = None,
response: Optional[Union[dict, str]] = None,
status_code: Optional[int] = None,
block: bool = False,
# Wait/Assert options
state: Optional[str] = None,
event: Optional[str] = None,
expected: Optional[str] = None,
not_: bool = False, # For negative assertions
# Options
timeout: Optional[int] = None,
full_page: bool = False,
tab_index: Optional[int] = None,
cdp_endpoint: Optional[str] = None,
headless: Optional[bool] = None,
# Storage
cookies: Optional[list[dict]] = None,
storage_type: Optional[str] = None,
storage_data: Optional[dict] = None,
# Auth
auth_file: Optional[str] = None,
# Dialog
accept: bool = True,
prompt_text: Optional[str] = None,
# Frame
frame: Optional[str] = None,
# Trace
trace_path: Optional[str] = None,
# Filter
level: Optional[str] = None,
) -> dict[str, Any]:
"""Execute browser action with full Playwright API support."""
pool = await BrowserPool.get_instance()
timeout = timeout or self.timeout
sel = selector or ref
try:
# === Connection ===
if action == "connect":
endpoint = cdp_endpoint or self.cdp_endpoint
if not endpoint:
return {"error": "cdp_endpoint required"}
page = await pool.ensure_browser(headless=self.headless, cdp_endpoint=endpoint)
return {"success": True, "connected": True, "endpoint": endpoint, "url": page.url}
# === Device Emulation ===
if action == "emulate":
if not device:
return {"error": f"device required. Available: {list(DEVICES.keys())}"}
if device not in DEVICES:
return {"error": f"Unknown device. Available: {list(DEVICES.keys())}"}
page = await pool.ensure_browser(headless=self.headless, cdp_endpoint=self.cdp_endpoint, device=device)
settings = DEVICES[device]
return {"success": True, "device": device, **settings}
page = await self._get_page(device)
# === Core Page Navigation & Lifecycle ===
if action == "navigate":
if not url:
return {"error": "url required"}
resp = await page.goto(url, timeout=timeout, wait_until=state or "domcontentloaded")
return {
"success": True,
"url": page.url,
"title": await page.title(),
"status": resp.status if resp else None,
}
elif action == "set_content":
if not html:
return {"error": "html required"}
await page.set_content(html, timeout=timeout)
return {"success": True, "set_content": True}
elif action == "content":
return {"success": True, "html": await page.content()}
elif action == "url":
return {"success": True, "url": page.url}
elif action == "title":
return {"success": True, "title": await page.title()}
elif action == "reload":
resp = await page.reload(timeout=timeout)
return {"success": True, "url": page.url, "status": resp.status if resp else None}
elif action == "go_back":
resp = await page.go_back(timeout=timeout)
return {"success": True, "url": page.url, "navigated": resp is not None}
elif action == "go_forward":
resp = await page.go_forward(timeout=timeout)
return {"success": True, "url": page.url, "navigated": resp is not None}
# === Input ===
elif action == "click":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.click(timeout=timeout, button=button or "left")
return {"success": True, "clicked": sel}
elif action == "dblclick":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.dblclick(timeout=timeout)
return {"success": True, "double_clicked": sel}
elif action == "type":
if not sel or text is None:
return {"error": "selector and text required"}
loc = self._get_locator(page, sel, frame)
await loc.type(text, timeout=timeout)
return {"success": True, "typed": len(text), "selector": sel}
elif action == "fill":
if not sel or text is None:
return {"error": "selector and text required"}
loc = self._get_locator(page, sel, frame)
await loc.fill(text, timeout=timeout)
return {"success": True, "filled": sel}
elif action == "clear":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.clear(timeout=timeout)
return {"success": True, "cleared": sel}
elif action == "press":
if not key:
return {"error": "key required"}
if sel:
loc = self._get_locator(page, sel, frame)
await loc.press(key, timeout=timeout)
else:
await page.keyboard.press(key)
return {"success": True, "pressed": key}
# === Forms ===
elif action == "select_option":
if not sel or value is None:
return {"error": "selector and value required"}
loc = self._get_locator(page, sel, frame)
selected = await loc.select_option(value if isinstance(value, list) else [value], timeout=timeout)
return {"success": True, "selected": selected}
elif action == "check":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.check(timeout=timeout)
return {"success": True, "checked": sel}
elif action == "uncheck":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.uncheck(timeout=timeout)
return {"success": True, "unchecked": sel}
elif action == "upload":
if not sel or not files:
return {"error": "selector and files required"}
loc = self._get_locator(page, sel, frame)
await loc.set_input_files(files, timeout=timeout)
return {"success": True, "uploaded": len(files)}
# === Mouse ===
elif action == "hover":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.hover(timeout=timeout)
return {"success": True, "hovered": sel}
elif action == "drag":
if not sel or not target_selector:
return {"error": "selector and target_selector required"}
await page.drag_and_drop(sel, target_selector, timeout=timeout)
return {"success": True, "dragged": sel, "to": target_selector}
elif action == "mouse_move":
if x is None or y is None:
return {"error": "x and y required"}
await page.mouse.move(x, y)
return {"success": True, "moved_to": {"x": x, "y": y}}
elif action == "mouse_down":
await page.mouse.down(button=button or "left")
return {"success": True, "button_down": button or "left"}
elif action == "mouse_up":
await page.mouse.up(button=button or "left")
return {"success": True, "button_up": button or "left"}
elif action == "mouse_wheel":
await page.mouse.wheel(delta_x or 0, delta_y or 0)
return {"success": True, "scrolled": {"delta_x": delta_x or 0, "delta_y": delta_y or 0}}
elif action == "scroll":
if sel:
loc = self._get_locator(page, sel, frame)
await loc.scroll_into_view_if_needed(timeout=timeout)
return {"success": True, "scrolled_to": sel}
else:
await page.evaluate(f"window.scrollBy({delta_x or 0}, {delta_y or 300})")
return {"success": True, "scrolled": {"delta_x": delta_x or 0, "delta_y": delta_y or 300}}
# === Touch ===
elif action == "tap":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.tap(timeout=timeout)
return {"success": True, "tapped": sel}
elif action == "swipe":
if not sel or not direction:
return {"error": "selector and direction required"}
loc = self._get_locator(page, sel, frame)
box = await loc.bounding_box()
if not box:
return {"error": "Element not visible"}
cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
dist = distance or 200
offsets = {"left": (-dist, 0), "right": (dist, 0), "up": (0, -dist), "down": (0, dist)}
dx, dy = offsets.get(direction, (0, 0))
await page.touchscreen.tap(cx, cy)
await page.mouse.move(cx, cy)
await page.mouse.down()
await page.mouse.move(cx + dx, cy + dy, steps=10)
await page.mouse.up()
return {"success": True, "swiped": sel, "direction": direction}
elif action == "pinch":
if not sel:
return {"error": "selector required"}
zoom = scale or 0.5
await page.evaluate(
f"""(sel) => {{
const el = document.querySelector(sel);
if (el) el.dispatchEvent(new WheelEvent('wheel', {{deltaY: {"-100" if zoom > 1 else "100"}, ctrlKey: true, bubbles: true}}));
}}""",
sel,
)
return {"success": True, "pinched": sel, "scale": zoom}
# === Locator Creation ===
elif action == "locator":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
cnt = await loc.count()
return {
"success": True,
"selector": sel,
"count": cnt,
"visible": await loc.first.is_visible() if cnt > 0 else False,
}
elif action == "frame_locator":
if not sel:
return {"error": "selector required"}
# Just validate frame exists
frame_loc = page.frame_locator(sel)
return {"success": True, "frame": sel, "note": "Use frame parameter in subsequent actions"}
# === Built-in Locators ===
elif action == "get_by_role":
if not role:
return {"error": "role required"}
loc = page.get_by_role(role, name=name, exact=exact)
cnt = await loc.count()
return {"success": True, "role": role, "name": name, "count": cnt}
elif action == "get_by_text":
if not text:
return {"error": "text required"}
loc = page.get_by_text(text, exact=exact)
return {"success": True, "text": text, "count": await loc.count()}
elif action == "get_by_label":
if not text:
return {"error": "text required"}
loc = page.get_by_label(text, exact=exact)
return {"success": True, "label": text, "count": await loc.count()}
elif action == "get_by_placeholder":
if not text:
return {"error": "text required"}
loc = page.get_by_placeholder(text, exact=exact)
return {"success": True, "placeholder": text, "count": await loc.count()}
elif action == "get_by_test_id":
if not text:
return {"error": "text required"}
loc = page.get_by_test_id(text)
return {"success": True, "test_id": text, "count": await loc.count()}
elif action == "get_by_alt_text":
if not text:
return {"error": "text required"}
loc = page.get_by_alt_text(text, exact=exact)
return {"success": True, "alt_text": text, "count": await loc.count()}
elif action == "get_by_title":
if not text:
return {"error": "text required"}
loc = page.get_by_title(text, exact=exact)
return {"success": True, "title": text, "count": await loc.count()}
# === Locator Composition ===
elif action == "first":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame).first
visible = await loc.is_visible()
return {"success": True, "first": True, "visible": visible}
elif action == "last":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame).last
visible = await loc.is_visible()
return {"success": True, "last": True, "visible": visible}
elif action == "nth":
if not sel or index is None:
return {"error": "selector and index required"}
loc = self._get_locator(page, sel, frame).nth(index)
visible = await loc.is_visible()
return {"success": True, "nth": index, "visible": visible}
elif action == "filter":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
filter_opts = {}
if has_text:
filter_opts["has_text"] = has_text
if has_not_text:
filter_opts["has_not_text"] = has_not_text
if has:
filter_opts["has"] = page.locator(has)
if filter_opts:
loc = loc.filter(**filter_opts)
return {"success": True, "filtered": True, "count": await loc.count()}
elif action == "all":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
elements = await loc.all()
results = []
for i, el in enumerate(elements):
results.append(
{
"index": i,
"visible": await el.is_visible(),
"text": await el.text_content(),
}
)
return {"success": True, "count": len(results), "elements": results[:20]} # Limit to 20
elif action == "count":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "count": await loc.count()}
# === Content Extraction ===
elif action == "get_text":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "text": await loc.text_content(timeout=timeout)}
elif action == "get_inner_text":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "inner_text": await loc.inner_text(timeout=timeout)}
elif action == "get_attribute":
if not sel or not attribute:
return {"error": "selector and attribute required"}
loc = self._get_locator(page, sel, frame)
return {
"success": True,
"attribute": attribute,
"value": await loc.get_attribute(attribute, timeout=timeout),
}
elif action == "get_value":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "value": await loc.input_value(timeout=timeout)}
elif action == "get_html":
if sel:
loc = self._get_locator(page, sel, frame)
return {"success": True, "html": await loc.inner_html(timeout=timeout)}
return {"success": True, "html": await page.content()}
elif action == "get_bounding_box":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
box = await loc.bounding_box(timeout=timeout)
return {"success": True, "bounding_box": box} if box else {"error": "Element not visible"}
# === State Checks ===
elif action == "is_visible":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "visible": await loc.is_visible(timeout=timeout)}
elif action == "is_hidden":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "hidden": await loc.is_hidden(timeout=timeout)}
elif action == "is_enabled":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "enabled": await loc.is_enabled(timeout=timeout)}
elif action == "is_editable":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "editable": await loc.is_editable(timeout=timeout)}
elif action == "is_checked":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
return {"success": True, "checked": await loc.is_checked(timeout=timeout)}
# === Assertions (expect) ===
elif action.startswith("expect_"):
from playwright.async_api import expect
if action == "expect_url":
pattern = expected or url or pattern
if not pattern:
return {"error": "expected URL pattern required"}
try:
await expect(page).to_have_url(
re.compile(pattern) if "*" in pattern else pattern, timeout=timeout
)
return {"success": True, "assertion": "url", "passed": True}
except Exception as e:
return {"success": False, "assertion": "url", "passed": False, "error": str(e)}
elif action == "expect_title":
pattern = expected or text
if not pattern:
return {"error": "expected title required"}
try:
await expect(page).to_have_title(
re.compile(pattern) if "*" in pattern else pattern, timeout=timeout
)
return {"success": True, "assertion": "title", "passed": True}
except Exception as e:
return {"success": False, "assertion": "title", "passed": False, "error": str(e)}
elif not sel:
return {"error": "selector required for element assertions"}
loc = self._get_locator(page, sel, frame)
assertion_type = action.replace("expect_", "")
try:
if assertion_type == "visible":
if not_:
await expect(loc).not_to_be_visible(timeout=timeout)
else:
await expect(loc).to_be_visible(timeout=timeout)
elif assertion_type == "hidden":
if not_:
await expect(loc).not_to_be_hidden(timeout=timeout)
else:
await expect(loc).to_be_hidden(timeout=timeout)
elif assertion_type == "enabled":
if not_:
await expect(loc).not_to_be_enabled(timeout=timeout)
else:
await expect(loc).to_be_enabled(timeout=timeout)
elif assertion_type == "text":
if not expected and not text:
return {"error": "expected text required"}
exp = expected or text
if not_:
await expect(loc).not_to_have_text(exp, timeout=timeout)
else:
await expect(loc).to_have_text(exp, timeout=timeout)
elif assertion_type == "value":
if not expected and not value:
return {"error": "expected value required"}
exp = expected or value
if not_:
await expect(loc).not_to_have_value(exp, timeout=timeout)
else:
await expect(loc).to_have_value(exp, timeout=timeout)
elif assertion_type == "checked":
if not_:
await expect(loc).not_to_be_checked(timeout=timeout)
else:
await expect(loc).to_be_checked(timeout=timeout)
elif assertion_type == "count":
if index is None:
return {"error": "index (expected count) required"}
await expect(loc).to_have_count(index, timeout=timeout)
elif assertion_type == "attribute":
if not attribute or not expected:
return {"error": "attribute and expected required"}
if not_:
await expect(loc).not_to_have_attribute(attribute, expected, timeout=timeout)
else:
await expect(loc).to_have_attribute(attribute, expected, timeout=timeout)
else:
return {"error": f"Unknown assertion: {assertion_type}"}
return {"success": True, "assertion": assertion_type, "passed": True, "selector": sel}
except Exception as e:
return {
"success": False,
"assertion": assertion_type,
"passed": False,
"selector": sel,
"error": str(e),
}
# === Page Actions ===
elif action == "screenshot":
opts = {"full_page": full_page, "type": "png"}
if sel:
loc = self._get_locator(page, sel, frame)
data = await loc.screenshot(**opts)
else:
data = await page.screenshot(**opts)
return {"success": True, "format": "png", "size": len(data), "base64": base64.b64encode(data).decode()}
elif action == "pdf":
data = await page.pdf()
return {"success": True, "format": "pdf", "size": len(data), "base64": base64.b64encode(data).decode()}
elif action == "snapshot":
return {
"success": True,
"url": page.url,
"title": await page.title(),
"snapshot": await page.accessibility.snapshot(),
}
elif action == "evaluate":
if not code:
return {"error": "code required"}
result = await page.evaluate(code)
return {"success": True, "result": result}
elif action == "focus":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.focus(timeout=timeout)
return {"success": True, "focused": sel}
elif action == "blur":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.blur(timeout=timeout)
return {"success": True, "blurred": sel}
elif action == "highlight":
if not sel:
return {"error": "selector required"}
loc = self._get_locator(page, sel, frame)
await loc.highlight()
return {"success": True, "highlighted": sel}
# === Wait Primitives ===
elif action == "wait":
if sel:
loc = self._get_locator(page, sel, frame)
await loc.wait_for(timeout=timeout, state=state or "visible")
return {"success": True, "found": sel}
elif timeout:
await asyncio.sleep(timeout / 1000)
return {"success": True, "waited_ms": timeout}
return {"error": "selector or timeout required"}
elif action == "wait_for_load":
await page.wait_for_load_state(state or "load", timeout=timeout)
return {"success": True, "state": state or "load"}
elif action == "wait_for_url":
if not pattern and not url:
return {"error": "pattern or url required"}
await page.wait_for_url(pattern or url, timeout=timeout)
return {"success": True, "url": page.url}
elif action == "wait_for_event":
if not event:
return {"error": "event required (request, response, download, filechooser, popup)"}
result = await page.wait_for_event(event, timeout=timeout)
if event == "request":
return {"success": True, "event": event, "url": result.url, "method": result.method}
elif event == "response":
return {"success": True, "event": event, "url": result.url, "status": result.status}
elif event == "download":
return {"success": True, "event": event, "filename": result.suggested_filename}
return {"success": True, "event": event}
elif action == "wait_for_request":
if not pattern:
return {"error": "pattern required"}
req = await page.wait_for_request(pattern, timeout=timeout)
return {"success": True, "url": req.url, "method": req.method}
elif action == "wait_for_response":
if not pattern:
return {"error": "pattern required"}
resp = await page.wait_for_response(pattern, timeout=timeout)
return {"success": True, "url": resp.url, "status": resp.status}
elif action == "wait_for_function":
if not code:
return {"error": "code (JavaScript function) required"}
await page.wait_for_function(code, timeout=timeout)
return {"success": True, "function_returned_truthy": True}
# === Viewport/Device ===
elif action == "viewport":
if width is None or height is None:
return {"success": True, "viewport": page.viewport_size}
await page.set_viewport_size({"width": width, "height": height})
return {"success": True, "viewport": {"width": width, "height": height}}
elif action == "geolocation":
if latitude is None or longitude is None:
return {"error": "latitude and longitude required"}
await pool._context.set_geolocation(
{"latitude": latitude, "longitude": longitude, "accuracy": accuracy or 100}
)
return {"success": True, "geolocation": {"lat": latitude, "lon": longitude}}
elif action == "permissions":
if not permission:
return {"error": "permission required"}
await pool._context.grant_permissions([permission])
return {"success": True, "granted": permission}
# === Network ===
elif action == "route":
if not pattern:
return {"error": "pattern required"}
async def handle(route: Route):
if block:
await route.abort()
elif response:
body = json.dumps(response) if isinstance(response, dict) else response
await route.fulfill(status=status_code or 200, content_type="application/json", body=body)
else:
await route.continue_()
await page.route(pattern, handle)
pool._state.routes[pattern] = {"block": block, "mock": response is not None}
return {"success": True, "route": pattern}
elif action == "unroute":
if not pattern:
return {"error": "pattern required"}
await page.unroute(pattern)
pool._state.routes.pop(pattern, None)
return {"success": True, "unrouted": pattern}
# === Storage ===
elif action == "cookies":
if cookies:
await pool._context.add_cookies(cookies)
return {"success": True, "set_cookies": len(cookies)}
return {"success": True, "cookies": await pool._context.cookies()}
elif action == "clear_cookies":
await pool._context.clear_cookies()
return {"success": True, "cleared_cookies": True}
elif action == "storage":
st = storage_type or "local"
store = "localStorage" if st == "local" else "sessionStorage"
if storage_data:
for k, v in storage_data.items():
await page.evaluate(
f"{store}.setItem('{k}', '{json.dumps(v) if isinstance(v, (dict, list)) else v}')"
)
return {"success": True, "set_keys": list(storage_data.keys())}
return {"success": True, "data": await page.evaluate(f"Object.fromEntries(Object.entries({store}))")}
elif action == "storage_state":
if not auth_file:
return {"error": "auth_file required"}
path = Path(auth_file)
if path.exists():
storage = json.loads(path.read_text())
await pool._context.add_cookies(storage.get("cookies", []))
return {"success": True, "loaded": auth_file}
storage_state = await pool._context.storage_state()
path.write_text(json.dumps(storage_state, indent=2))
return {"success": True, "saved": auth_file}
# === Events ===
elif action == "on":
if not event:
return {"error": "event required"}
# Events are auto-handled by _setup_page_listeners
return {
"success": True,
"listening": event,
"note": "Use console/errors/dialog actions to retrieve captured events",
}
elif action == "off":
return {"success": True, "note": "Event listeners managed automatically"}
# === Dialogs ===
elif action == "dialog":
if pool._state.pending_dialog:
d = pool._state.pending_dialog
if accept:
await d.accept(prompt_text or "")
else:
await d.dismiss()
pool._state.pending_dialog = None
return {"success": True, "type": d.type, "message": d.message, "accepted": accept}
return {"error": "No pending dialog"}
# === Frames ===
elif action == "frame":
if not sel:
return {"error": "selector required for frame"}
return {"success": True, "frame": sel, "note": "Use frame parameter in subsequent actions"}
elif action == "main_frame":
return {"success": True, "frame": "main"}
# === File Chooser & Downloads ===
elif action == "file_chooser":
if pool._state.pending_file_chooser:
fc = pool._state.pending_file_chooser
if files:
await fc.set_files(files)
pool._state.pending_file_chooser = None
return {"success": True, "uploaded": len(files)}
return {"success": True, "file_chooser_pending": True, "multiple": fc.is_multiple}
return {"error": "No pending file chooser. Trigger an upload first."}
elif action == "download":
if pool._state.pending_download:
d = pool._state.pending_download
path = await d.path()
pool._state.pending_download = None
return {
"success": True,
"filename": d.suggested_filename,
"path": str(path) if path else None,
"url": d.url,
}
# Trigger download by clicking
if sel:
async with page.expect_download(timeout=timeout) as dl:
await page.click(sel)
d = await dl.value
return {"success": True, "filename": d.suggested_filename, "url": d.url}
return {"error": "No pending download and no selector to click"}
# === Console/Errors ===
elif action == "console":
msgs = pool._state.console_messages
if level:
msgs = [m for m in msgs if m["type"] == level]
return {"success": True, "messages": msgs[-50:], "count": len(msgs)} # Last 50
elif action == "errors":
return {"success": True, "errors": pool._state.page_errors[-20:], "count": len(pool._state.page_errors)}
# === Browser/Context ===
elif action == "close":
await pool.close()
return {"success": True, "closed": True}
elif action == "new_page" or action == "new_tab":
new_page = await pool.new_page(url)
return {"success": True, "page_index": len(pool.pages) - 1, "url": new_page.url}
elif action == "new_context":
context = await pool.new_context(device=device)
page = await context.new_page()
pool._page = page
pool._pages.append(page)
pool._setup_page_listeners(page)
if url:
await page.goto(url)
return {"success": True, "context": "new", "device": device, "isolated": True, "url": page.url}
elif action == "close_tab":
await pool.close_page(tab_index)
return {"success": True, "remaining_pages": len(pool.pages)}
elif action == "tabs":
if tab_index is not None:
try:
page = await pool.switch_page(tab_index)
return {"success": True, "switched_to": tab_index, "url": page.url}
except ValueError as e:
return {"error": str(e)}
return {
"success": True,
"count": len(pool.pages),
"tabs": [{"index": i, "url": p.url} for i, p in enumerate(pool.pages)],
}
elif action == "set_headless":
new_headless = headless if headless is not None else not pool._headless
current_url = page.url if page else None
old_mode = "headless" if pool._headless else "headed"
await pool.close()
self.headless = new_headless
page = await pool.ensure_browser(headless=new_headless)
if current_url and current_url != "about:blank":
await page.goto(current_url)
return {
"success": True,
"previous_mode": old_mode,
"current_mode": "headless" if new_headless else "headed",
}
elif action == "status":
return {
"success": True,
"initialized": pool._initialized,
"headless": pool._headless,
"device": pool._device,
"pages": len(pool.pages),
"contexts": len(pool._contexts),
"current_url": page.url if page else None,
"console_messages": len(pool._state.console_messages),
"errors": len(pool._state.page_errors),
"routes": list(pool._state.routes.keys()),
"tracing": pool._state.tracing,
}
# === Debug ===
elif action == "trace_start":
if pool._state.tracing:
return {"error": "Tracing already active"}
await pool._context.tracing.start(screenshots=True, snapshots=True, sources=True)
pool._state.tracing = True
return {"success": True, "tracing": True}
elif action == "trace_stop":
if not pool._state.tracing:
return {"error": "Tracing not active"}
path = trace_path or f"trace-{int(asyncio.get_event_loop().time())}.zip"
await pool._context.tracing.stop(path=path)
pool._state.tracing = False
return {"success": True, "trace_path": path}
else:
return {"error": f"Unknown action: {action}"}
except Exception as e:
logger.exception(f"Browser action failed: {action}")
return {"error": str(e), "action": action}
def register(self, mcp_server: FastMCP) -> None:
"""Register the browser tool with an MCP server."""
tool_instance = self
@mcp_server.tool(name=self.name, description=self.description)
async def browser(
action: Action,
url: Annotated[Optional[str], Field(description="URL")] = None,
selector: Annotated[Optional[str], Field(description="CSS/XPath selector")] = None,
ref: Annotated[Optional[str], Field(description="Alias for selector")] = None,
target_selector: Annotated[Optional[str], Field(description="Target for drag")] = None,
text: Annotated[Optional[str], Field(description="Text for type/fill/locators")] = None,
value: Annotated[Optional[str], Field(description="Value for select/assertions")] = None,
key: Annotated[Optional[str], Field(description="Key for press")] = None,
code: Annotated[Optional[str], Field(description="JavaScript code")] = None,
html: Annotated[Optional[str], Field(description="HTML for set_content")] = None,
attribute: Annotated[Optional[str], Field(description="Attribute name")] = None,
role: Annotated[Optional[str], Field(description="ARIA role")] = None,
name: Annotated[Optional[str], Field(description="Accessible name")] = None,
exact: Annotated[bool, Field(description="Exact text match")] = False,
index: Annotated[Optional[int], Field(description="Index for nth/count")] = None,
has_text: Annotated[Optional[str], Field(description="Filter by text")] = None,
has_not_text: Annotated[Optional[str], Field(description="Filter excluding text")] = None,
has: Annotated[Optional[str], Field(description="Filter by nested selector")] = None,
files: Annotated[Optional[list[str]], Field(description="Files for upload")] = None,
x: Annotated[Optional[int], Field(description="X coordinate")] = None,
y: Annotated[Optional[int], Field(description="Y coordinate")] = None,
button: Annotated[Optional[str], Field(description="Mouse button")] = None,
delta_x: Annotated[Optional[int], Field(description="Horizontal delta")] = None,
delta_y: Annotated[Optional[int], Field(description="Vertical delta")] = None,
direction: Annotated[Optional[str], Field(description="Swipe direction")] = None,
distance: Annotated[Optional[int], Field(description="Swipe distance")] = None,
scale: Annotated[Optional[float], Field(description="Pinch scale")] = None,
width: Annotated[Optional[int], Field(description="Viewport width")] = None,
height: Annotated[Optional[int], Field(description="Viewport height")] = None,
device: Annotated[Optional[str], Field(description="Device: mobile, tablet, laptop")] = None,
latitude: Annotated[Optional[float], Field(description="Geo latitude")] = None,
longitude: Annotated[Optional[float], Field(description="Geo longitude")] = None,
permission: Annotated[Optional[str], Field(description="Permission to grant")] = None,
pattern: Annotated[Optional[str], Field(description="URL pattern")] = None,
response: Annotated[Optional[dict], Field(description="Mock response")] = None,
status_code: Annotated[Optional[int], Field(description="Mock status")] = None,
block: Annotated[bool, Field(description="Block request")] = False,
state: Annotated[Optional[str], Field(description="Load state/wait state")] = None,
event: Annotated[Optional[str], Field(description="Event name")] = None,
expected: Annotated[Optional[str], Field(description="Expected value for assertions")] = None,
not_: Annotated[bool, Field(description="Negate assertion")] = False,
timeout: Annotated[Optional[int], Field(description="Timeout ms")] = None,
full_page: Annotated[bool, Field(description="Full page screenshot")] = False,
tab_index: Annotated[Optional[int], Field(description="Tab index")] = None,
cdp_endpoint: Annotated[Optional[str], Field(description="CDP endpoint")] = None,
headless: Annotated[Optional[bool], Field(description="Headless mode")] = None,
cookies: Annotated[Optional[list[dict]], Field(description="Cookies")] = None,
storage_type: Annotated[Optional[str], Field(description="local/session")] = None,
storage_data: Annotated[Optional[dict], Field(description="Storage data")] = None,
auth_file: Annotated[Optional[str], Field(description="Auth state file")] = None,
accept: Annotated[bool, Field(description="Accept dialog")] = True,
prompt_text: Annotated[Optional[str], Field(description="Dialog text")] = None,
frame: Annotated[Optional[str], Field(description="Frame selector")] = None,
trace_path: Annotated[Optional[str], Field(description="Trace output")] = None,
level: Annotated[Optional[str], Field(description="Console level")] = None,
) -> dict[str, Any]:
"""Complete browser automation with full Playwright API surface area."""
return await tool_instance.execute(
action=action,
url=url,
selector=selector,
ref=ref,
target_selector=target_selector,
text=text,
value=value,
key=key,
code=code,
html=html,
attribute=attribute,
role=role,
name=name,
exact=exact,
index=index,
has_text=has_text,
has_not_text=has_not_text,
has=has,
files=files,
x=x,
y=y,
button=button,
delta_x=delta_x,
delta_y=delta_y,
direction=direction,
distance=distance,
scale=scale,
width=width,
height=height,
device=device,
latitude=latitude,
longitude=longitude,
permission=permission,
pattern=pattern,
response=response,
status_code=status_code,
block=block,
state=state,
event=event,
expected=expected,
not_=not_,
timeout=timeout,
full_page=full_page,
tab_index=tab_index,
cdp_endpoint=cdp_endpoint,
headless=headless,
cookies=cookies,
storage_type=storage_type,
storage_data=storage_data,
auth_file=auth_file,
accept=accept,
prompt_text=prompt_text,
frame=frame,
trace_path=trace_path,
level=level,
)
def create_browser_tool(headless: bool = True, cdp_endpoint: Optional[str] = None) -> BrowserTool:
"""Create a browser tool instance."""
return BrowserTool(headless=headless, cdp_endpoint=cdp_endpoint)
async def launch_browser_server(port: int = 9222, headless: bool = False) -> str:
"""Launch a persistent browser server for cross-MCP sharing."""
if not PLAYWRIGHT_AVAILABLE:
raise RuntimeError("Playwright not installed")
pw = await async_playwright().start()
await pw.chromium.launch(
headless=headless,
args=[
f"--remote-debugging-port={port}",
"--disable-blink-features=AutomationControlled",
"--no-sandbox",
],
)
endpoint = f"http://localhost:{port}"
logger.info(f"Browser server launched at {endpoint}")
return endpoint
# Default tool instance
browser_tool = BrowserTool()