mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
The browser tool's bespoke register() returned execute()'s dict RAW, so FastMCP
flat-JSON-serialized a screenshot's ToolImage (base64 inline, 250K chars) →
overflowed the agent context and wedged the run — the SAME bug main's
ImageContent converter (711eb498) fixed for every OTHER tool via
BaseTool.register→_result_to_mcp, but the browser tool bypassed that path.
Now both registration paths share the one converter: nested ToolImage anywhere
in the result becomes a native MCP ImageContent block the client SEES. One way.
2612 lines
105 KiB
Python
2612 lines
105 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, ToolImage
|
|
from hanzo_tools.core.unified import _result_to_mcp
|
|
|
|
# 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__)
|
|
|
|
|
|
def _load_config() -> dict:
|
|
"""Load browser config from ~/.hanzo/extension/config.json."""
|
|
config_path = Path.home() / ".hanzo" / "extension" / "config.json"
|
|
try:
|
|
if config_path.exists():
|
|
return json.loads(config_path.read_text())
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _save_config(config: dict) -> None:
|
|
"""Save browser config to ~/.hanzo/extension/config.json."""
|
|
config_dir = Path.home() / ".hanzo" / "extension"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
config_path = config_dir / "config.json"
|
|
try:
|
|
config_path.write_text(json.dumps(config, indent=2))
|
|
except Exception as e:
|
|
logger.warning(f"Failed to save config: {e}")
|
|
|
|
|
|
def _save_capture(data: bytes, fmt: str, path: Optional[str] = None) -> dict:
|
|
"""Persist a screenshot/pdf capture to disk and return a COMPACT result.
|
|
|
|
A full-page PNG inlined as base64 (100K+ chars) overflows an agent's context
|
|
window and wedges the whole run — the single biggest cause of "the browser
|
|
tool hangs". So captures ALWAYS go to a file and we return its ``path`` + byte
|
|
``size``; ``base64`` is inlined ONLY for a small PNG (<= 40 KB raw) that a
|
|
caller can display without blowing the context. Honors a caller-supplied path.
|
|
"""
|
|
import uuid
|
|
|
|
if path:
|
|
p = Path(path).expanduser()
|
|
if p.parent and not p.parent.exists():
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
d = Path.home() / ".hanzo" / "screenshots"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
p = d / f"capture-{uuid.uuid4().hex[:12]}.{fmt}"
|
|
try:
|
|
p.write_bytes(data)
|
|
except Exception as e: # never let a save failure wedge the action
|
|
logger.warning(f"screenshot save failed ({e}); returning base64 inline")
|
|
return {"success": True, "format": fmt, "size": len(data), "base64": base64.b64encode(data).decode()}
|
|
out = {"success": True, "format": fmt, "size": len(data), "path": str(p)}
|
|
if fmt == "png" and len(data) <= 40000:
|
|
out["base64"] = base64.b64encode(data).decode()
|
|
return out
|
|
|
|
|
|
def _extract_b64(text: str) -> Optional[str]:
|
|
"""Pull the base64 payload out of a screenshot response — whether it's raw
|
|
base64, a data: URL, or JSON under data/base64/screenshot (incl. a nested
|
|
CDP ``{"result": {"data": ...}}``). Returns None when no payload is found."""
|
|
if not text:
|
|
return None
|
|
t = text.strip()
|
|
if t.startswith("{"):
|
|
try:
|
|
obj = json.loads(t)
|
|
except Exception:
|
|
return None
|
|
if isinstance(obj, dict):
|
|
for k in ("data", "base64", "screenshot"):
|
|
v = obj.get(k)
|
|
if isinstance(v, str) and v:
|
|
return v.split(",", 1)[-1] if v.startswith("data:") else v
|
|
r = obj.get("result")
|
|
if isinstance(r, dict):
|
|
for k in ("data", "base64", "screenshot"):
|
|
v = r.get(k)
|
|
if isinstance(v, str) and v:
|
|
return v.split(",", 1)[-1] if v.startswith("data:") else v
|
|
return None
|
|
if t.startswith("data:image"):
|
|
return t.split(",", 1)[-1]
|
|
_alpha = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r")
|
|
if len(t) > 100 and set(t[:256]) <= _alpha:
|
|
return t
|
|
return None
|
|
|
|
|
|
def get_backend() -> str:
|
|
"""Get configured browser backend.
|
|
|
|
Priority: BROWSER_BACKEND env var > ~/.hanzo/extension/config.json > "auto"
|
|
|
|
Values: firefox | chrome | extension | playwright | auto
|
|
"""
|
|
# Env var takes precedence
|
|
env_val = os.environ.get("BROWSER_BACKEND", "").strip().lower()
|
|
if env_val in ("firefox", "chrome", "extension", "playwright", "auto"):
|
|
return env_val
|
|
|
|
# Then config file
|
|
config = _load_config()
|
|
file_val = config.get("backend", "").strip().lower()
|
|
if file_val in ("firefox", "chrome", "extension", "playwright", "auto"):
|
|
return file_val
|
|
|
|
return "auto"
|
|
|
|
|
|
def _normalize_tab_id(tab_id: Union[str, int, None]) -> Union[str, int, None]:
|
|
"""Strip ``tab-`` prefix and coerce to int when possible."""
|
|
if tab_id is None:
|
|
return None
|
|
t = tab_id
|
|
if isinstance(t, str) and t.startswith("tab-"):
|
|
t = t[4:]
|
|
try:
|
|
return int(t)
|
|
except (TypeError, ValueError):
|
|
return t
|
|
|
|
|
|
async def _check_extension(browser: Optional[str] = None) -> bool:
|
|
"""Check if a browser provider is connected to the local zapd router."""
|
|
import asyncio
|
|
|
|
from hanzo_tools.browser.zapd_consumer import get_consumer
|
|
|
|
consumer = get_consumer()
|
|
if consumer is None:
|
|
return False
|
|
try:
|
|
return await asyncio.to_thread(consumer.resolve_browser, browser, None) is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _zap_method_for(action: str) -> str:
|
|
"""Map a browser-tool action onto the wire method the extension expects.
|
|
|
|
The Firefox/Chrome backgrounds dispatch CDP-style methods (``Page.navigate``,
|
|
``Runtime.evaluate``, ``hanzo.click`` …) when the field is ``method`` on
|
|
the JSON it receives. Over ZAP we send the same method name so the
|
|
extension handler is shared.
|
|
"""
|
|
# Decomplected API surface — three layers, orthogonal:
|
|
# 1) CDP-shape canonical names (Domain.method)
|
|
# 2) hanzo.* ergonomic aliases that compose CDP primitives
|
|
# 3) Python-side snake_case shortcuts that map to either
|
|
return {
|
|
# ─── Page lifecycle / navigation ────────────────────────────────
|
|
"navigate": "Page.navigate",
|
|
"reload": "Page.reload",
|
|
"go_back": "Page.goBack",
|
|
"go_forward": "Page.goForward",
|
|
"print_pdf": "Page.printToPDF",
|
|
"wait_for_navigation": "hanzo.waitForNavigation",
|
|
"wait_for_load_state": "Page.waitForLoadState",
|
|
|
|
# ─── Tabs / targets ─────────────────────────────────────────────
|
|
"tabs": "Target.getTargets",
|
|
"new_tab": "Target.createTarget",
|
|
"close_tab": "Target.closeTarget",
|
|
"activate_tab": "Target.activateTarget",
|
|
"url": "hanzo.url",
|
|
"title": "hanzo.title",
|
|
"tab_info": "hanzo.tabInfo",
|
|
"list_tabs": "hanzo.listTabs",
|
|
"history": "hanzo.getHistory",
|
|
|
|
# ─── Observation ────────────────────────────────────────────────
|
|
"screenshot": "hanzo.screenshot",
|
|
"page_info": "hanzo.getPageInfo",
|
|
"ax_tree": "Accessibility.getFullAXTree",
|
|
"status": "Browser.getVersion",
|
|
|
|
# ─── DOM read ───────────────────────────────────────────────────
|
|
"get_text": "hanzo.getText",
|
|
"get_html": "hanzo.getHTML",
|
|
"get_attribute": "hanzo.getAttribute",
|
|
"get_element_info": "hanzo.getElementInfo",
|
|
"query_one": "DOM.querySelector",
|
|
"query_all": "hanzo.querySelectorAll",
|
|
"list_form": "hanzo.listForm",
|
|
"computed_styles": "hanzo.getComputedStyles",
|
|
"bounding_rects": "hanzo.getBoundingRects",
|
|
|
|
# ─── DOM write — selector-based ─────────────────────────────────
|
|
"click": "hanzo.click",
|
|
"dblclick": "hanzo.dblclick",
|
|
"hover": "hanzo.hover",
|
|
"fill": "hanzo.fill",
|
|
"check": "hanzo.check",
|
|
"uncheck": "hanzo.uncheck",
|
|
"select": "hanzo.select",
|
|
"type": "hanzo.type",
|
|
"clear": "hanzo.clear",
|
|
"focus": "DOM.focus",
|
|
"scroll_into_view": "DOM.scrollIntoView",
|
|
"set_text": "hanzo.setText",
|
|
"set_html": "hanzo.setHTML",
|
|
"set_attribute": "hanzo.setAttribute",
|
|
"remove_attribute": "hanzo.removeAttribute",
|
|
|
|
# ─── DOM write — CSP-safe text/label-based (RECOMMENDED) ─────────
|
|
"click_text": "hanzo.clickByText",
|
|
"fill_label": "hanzo.fillByLabel",
|
|
"find_by_text": "hanzo.findByText",
|
|
"submit_form": "hanzo.submitForm",
|
|
"upload_file": "hanzo.uploadFile",
|
|
|
|
# ─── Keyboard / Mouse ───────────────────────────────────────────
|
|
"press": "hanzo.press",
|
|
"press_key": "Input.dispatchKeyEvent",
|
|
"mouse_event": "Input.dispatchMouseEvent",
|
|
"scroll": "hanzo.scroll",
|
|
"scroll_wheel": "Input.scrollWheel",
|
|
|
|
# ─── Wait / Observe ─────────────────────────────────────────────
|
|
"wait_for_text": "hanzo.waitForText",
|
|
"wait_for_mutation": "hanzo.waitForMutation",
|
|
"wait_for_selector": "hanzo.waitForSelector",
|
|
"observe_start": "hanzo.observe",
|
|
"observe_read": "hanzo.observeRead",
|
|
"observe_stop": "hanzo.observeStop",
|
|
|
|
# ─── Dialog ─────────────────────────────────────────────────────
|
|
"dialog_accept": "hanzo.dialogAccept",
|
|
|
|
# ─── Scripting (works on non-CSP-strict pages only) ─────────────
|
|
"evaluate": "Runtime.evaluate",
|
|
"inject_script": "hanzo.injectScript",
|
|
"inject_css": "hanzo.injectCSS",
|
|
|
|
# ─── Cookies / Storage ──────────────────────────────────────────
|
|
"cookies": "hanzo.getCookies",
|
|
"local_storage_get": "hanzo.getLocalStorage",
|
|
"local_storage_set": "hanzo.setLocalStorage",
|
|
|
|
# ─── Network monitoring ─────────────────────────────────────────
|
|
"monitor_start": "monitor.start",
|
|
"monitor_stop": "monitor.stop",
|
|
"monitor_console_logs": "monitor.consoleLogs",
|
|
"monitor_console_errors": "monitor.consoleErrors",
|
|
"monitor_network_logs": "monitor.networkLogs",
|
|
"monitor_network_errors": "monitor.networkErrors",
|
|
"monitor_network_success": "monitor.networkSuccess",
|
|
|
|
# ─── Audit / Lighthouse-style ───────────────────────────────────
|
|
"audit_accessibility": "audit.accessibility",
|
|
"audit_performance": "audit.performance",
|
|
"audit_seo": "audit.seo",
|
|
|
|
# ─── HTTP fetch through the browser (uses page origin) ──────────
|
|
"fetch": "hanzo.fetch",
|
|
}.get(action, action)
|
|
|
|
|
|
def _zap_params(
|
|
action: str,
|
|
*,
|
|
tab_id: Union[str, int, None] = None,
|
|
url: Optional[str] = None,
|
|
selector: Optional[str] = None,
|
|
text: Optional[str] = None,
|
|
label: Optional[str] = None,
|
|
value: Optional[str] = None,
|
|
code: Optional[str] = None,
|
|
expression: Optional[str] = None,
|
|
full_page: Optional[bool] = None,
|
|
**rest,
|
|
) -> dict:
|
|
"""Translate the extension-tool kwargs into wire params."""
|
|
params: dict[str, Any] = {}
|
|
if url is not None:
|
|
params["url"] = url
|
|
if selector is not None:
|
|
params["selector"] = selector
|
|
if value is not None:
|
|
params["value"] = value
|
|
if text is not None:
|
|
params["text"] = text
|
|
if label is not None:
|
|
params["label"] = label
|
|
expr = code or expression
|
|
if expr is not None:
|
|
params["expression"] = expr
|
|
if full_page is not None:
|
|
params["fullPage"] = full_page
|
|
norm_tab = _normalize_tab_id(tab_id)
|
|
if norm_tab is not None:
|
|
params["tabId"] = norm_tab
|
|
# Forward any extra kwargs the caller passed (action-specific fields).
|
|
for k, v in rest.items():
|
|
if v is None:
|
|
continue
|
|
if k in {
|
|
"key",
|
|
"index",
|
|
"tab_index",
|
|
"timeout",
|
|
"state",
|
|
"level",
|
|
"expression",
|
|
"scope", # for click_text — "default" or "all"
|
|
"limit", # for query_all
|
|
"outer", # for get_html
|
|
}:
|
|
params[k] = v
|
|
return params
|
|
|
|
|
|
# Current BiDi browsing context (active tab) for the Firefox backend.
|
|
_bidi_current_context: Optional[str] = None
|
|
|
|
|
|
async def _bidi_dispatch(
|
|
action: str,
|
|
*,
|
|
url: Optional[str] = None,
|
|
selector: Optional[str] = None,
|
|
text: Optional[str] = None,
|
|
code: Optional[str] = None,
|
|
key: Optional[str] = None,
|
|
) -> Optional[dict[str, Any]]:
|
|
"""Drive Firefox via WebDriver BiDi (real navigation + trusted input).
|
|
|
|
Returns a normalized result dict, or ``None`` to fall through to the
|
|
extension / Playwright backends when BiDi isn't available (Firefox not
|
|
launched with --remote-debugging-port) or the action isn't BiDi-mapped.
|
|
"""
|
|
global _bidi_current_context
|
|
try:
|
|
from hanzo_tools.browser.bidi_client import get_or_connect
|
|
except Exception:
|
|
return None
|
|
client = await get_or_connect(port=9222)
|
|
if client is None:
|
|
return None
|
|
|
|
async def _ctx() -> str:
|
|
global _bidi_current_context
|
|
ctxs = await client.list_contexts()
|
|
ids = [c.get("context") for c in ctxs if c.get("context")]
|
|
if _bidi_current_context in ids:
|
|
return _bidi_current_context
|
|
_bidi_current_context = ids[0] if ids else await client.create_context("tab")
|
|
return _bidi_current_context
|
|
|
|
def _val(ev: dict) -> Any:
|
|
return (ev or {}).get("result", {}).get("value")
|
|
|
|
try:
|
|
if action in ("navigate", "goto"):
|
|
c = await _ctx()
|
|
await client.navigate(c, url or "about:blank")
|
|
return {"success": True, "source": "bidi", "url": await client.get_url(c)}
|
|
if action in ("new_tab", "create_tab"):
|
|
_bidi_current_context = await client.create_context("tab")
|
|
if url:
|
|
await client.navigate(_bidi_current_context, url)
|
|
return {"success": True, "source": "bidi", "tab": _bidi_current_context}
|
|
if action in ("click", "tap"):
|
|
c = await _ctx()
|
|
r = await client.click_selector(c, selector or "")
|
|
return {"success": bool(r.get("clicked")), "source": "bidi", **r}
|
|
if action in ("type", "fill"):
|
|
c = await _ctx()
|
|
if selector:
|
|
await client.click_selector(c, selector)
|
|
await client.input_insert_text(c, text or "")
|
|
return {"success": True, "source": "bidi"}
|
|
if action in ("press", "press_key"):
|
|
c = await _ctx()
|
|
await client.input_key_press(c, key or "Enter")
|
|
return {"success": True, "source": "bidi"}
|
|
if action == "screenshot":
|
|
c = await _ctx()
|
|
return {"success": True, "source": "bidi", "image": ToolImage(data=await client.capture_screenshot(c), mime_type="image/png", alt="screenshot")}
|
|
if action == "evaluate":
|
|
c = await _ctx()
|
|
return {"success": True, "source": "bidi", "result": _val(await client.script_evaluate(c, code or ""))}
|
|
if action == "get_text":
|
|
c = await _ctx()
|
|
return {"success": True, "source": "bidi", "text": _val(await client.script_evaluate(c, "document.body ? document.body.innerText : ''"))}
|
|
if action in ("get_html", "content"):
|
|
c = await _ctx()
|
|
return {"success": True, "source": "bidi", "html": _val(await client.script_evaluate(c, "document.documentElement.outerHTML"))}
|
|
if action in ("url", "get_url"):
|
|
c = await _ctx()
|
|
return {"success": True, "source": "bidi", "url": await client.get_url(c)}
|
|
if action in ("title", "get_title"):
|
|
c = await _ctx()
|
|
return {"success": True, "source": "bidi", "title": _val(await client.script_evaluate(c, "document.title"))}
|
|
if action == "tabs":
|
|
ctxs = await client.list_contexts()
|
|
return {"success": True, "source": "bidi",
|
|
"tabs": [{"context": c.get("context"), "url": c.get("url")} for c in ctxs]}
|
|
except Exception as e:
|
|
return {"error": str(e), "source": "bidi"}
|
|
return None # unmapped action → fall through to extension / Playwright
|
|
|
|
|
|
async def _extension_command(
|
|
action: str,
|
|
browser: Optional[str] = None,
|
|
tab_id: Optional[Union[str, int]] = None,
|
|
client_id: Optional[str] = None,
|
|
**kwargs,
|
|
) -> Optional[dict]:
|
|
"""Route a browser command to a provider via the local zapd router.
|
|
|
|
hanzo-mcp is a zapd *consumer*: it connects to ``~/.zap/run/zapd.sock``,
|
|
lists providers, and routes opaque commands to a ``browser:*`` provider.
|
|
No in-process server, no HTTP bridge, no :9224, no Playwright fallback.
|
|
"""
|
|
import asyncio
|
|
|
|
from hanzo_tools.browser.zapd_consumer import get_consumer
|
|
|
|
consumer = get_consumer()
|
|
if consumer is None:
|
|
return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap"}
|
|
|
|
try:
|
|
provider = await asyncio.to_thread(consumer.resolve_browser, browser, client_id)
|
|
except Exception as e:
|
|
return {"error": str(e), "transport": "native-zap"}
|
|
if not provider:
|
|
return {"error": "no browser provider connected over zapd", "transport": "native-zap"}
|
|
|
|
method = _zap_method_for(action)
|
|
params = _zap_params(action, tab_id=tab_id, **kwargs) or {}
|
|
str_params = {k: (v if isinstance(v, str) else str(v)) for k, v in params.items() if v is not None}
|
|
try:
|
|
raw = await asyncio.to_thread(consumer.route, provider, method, str_params)
|
|
except Exception as e:
|
|
return {"error": str(e), "transport": "native-zap"}
|
|
|
|
text = raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else raw
|
|
# A screenshot/pdf comes back as (JSON-wrapped) base64. Inlining a full-page
|
|
# PNG (100K+ chars) as text overflows the agent's context and wedges the whole
|
|
# run — the #1 cause of "the browser tool hangs". One way for images: return a
|
|
# ToolImage → the register() converter turns it into native MCP ImageContent the
|
|
# client SEES (no base64-in-text). A PDF isn't an image → persist to disk, path.
|
|
if action in ("screenshot", "pdf") and isinstance(text, str) and len(text) > 20000:
|
|
b64 = _extract_b64(text)
|
|
if b64:
|
|
try:
|
|
data = base64.b64decode(b64)
|
|
meta = {"transport": "native-zap", "source": "zapd", "provider": provider}
|
|
if action == "pdf":
|
|
return {**_save_capture(data, "pdf", kwargs.get("path")), **meta}
|
|
return {"success": True, "format": "png", "size": len(data),
|
|
"image": ToolImage.from_bytes(data, "image/png", alt="screenshot"), **meta}
|
|
except Exception as e: # fall back to raw on any decode failure
|
|
logger.warning(f"native-zap capture decode failed ({e}); returning raw")
|
|
return {"success": True, "transport": "native-zap", "source": "zapd", "provider": provider, "result": text}
|
|
|
|
|
|
# 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
|
|
"select_tab", # bring a specific tab to focus (Target.activateTarget)
|
|
"list_browsers", # list every connected extension provider
|
|
"set_default_browser", # persist the bridge's default browser pick
|
|
"use_browser", # alias of set_default_browser
|
|
"list_mcp_instances", # list all hanzo-mcps registered with the extension
|
|
"claim_browser", # take an exclusive lease on a browser for N seconds
|
|
"release_browser", # drop a previously-claimed lease
|
|
"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,
|
|
backend: Optional[str] = None,
|
|
):
|
|
self.headless = headless
|
|
self.cdp_endpoint = cdp_endpoint or os.environ.get("BROWSER_CDP_ENDPOINT")
|
|
self.backend = backend or get_backend()
|
|
self.timeout = 30000
|
|
# No server to start: native-browser commands route through the shared
|
|
# zapd router (~/.zap/run/zapd.sock) on demand via zapd_consumer.
|
|
|
|
@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,
|
|
tab_id: Optional[Union[str, int]] = None,
|
|
client_id: Optional[str] = None,
|
|
target_browser: Optional[str] = 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.
|
|
|
|
Automatically uses Hanzo browser extension if connected,
|
|
falling back to Playwright for headless automation.
|
|
"""
|
|
timeout = timeout or self.timeout
|
|
sel = selector or ref
|
|
|
|
# === LOCAL ACTIONS (answered from the zapd provider list) ===
|
|
if action in ("list_mcp_instances", "list_browsers", "browsers"):
|
|
from hanzo_tools.browser.zapd_consumer import get_consumer
|
|
|
|
consumer = get_consumer()
|
|
if consumer is None:
|
|
return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap"}
|
|
try:
|
|
provs = await asyncio.to_thread(consumer.list_providers)
|
|
except Exception as e:
|
|
return {"error": str(e), "transport": "native-zap"}
|
|
browsers = [p for p in provs if p.get("id", "").startswith("browser:")]
|
|
return {"success": True, "transport": "native-zap", "browsers": browsers, "count": len(browsers)}
|
|
|
|
if action in ("claim_browser", "release_browser"):
|
|
# Exclusive leases were an in-process-server concept. The shared
|
|
# zapd router has no lease frame — target a specific provider with
|
|
# `target_browser` ("chrome"|"firefox") or `client_id` instead.
|
|
return {
|
|
"success": True,
|
|
"transport": "native-zap",
|
|
"note": "zapd is a shared router with no exclusive lease; pass target_browser or client_id to address a specific provider.",
|
|
}
|
|
|
|
# === BiDi FAST-PATH (Firefox via WebDriver BiDi) ===
|
|
# When the firefox backend is targeted and Firefox exposes a BiDi remote
|
|
# agent (launched with --remote-debugging-port=9222), drive it directly:
|
|
# real navigation + TRUSTED input, no CDP (Firefox is BiDi-only). Falls
|
|
# through to the extension / Playwright paths when BiDi is unavailable or
|
|
# the action isn't BiDi-mapped.
|
|
bidi_target = target_browser or (self.backend if self.backend == "firefox" else None)
|
|
if bidi_target == "firefox":
|
|
bidi_res = await _bidi_dispatch(
|
|
action, url=url, selector=sel, text=text, code=code, key=key
|
|
)
|
|
if bidi_res is not None:
|
|
return bidi_res
|
|
|
|
# === BACKEND-AWARE ROUTING ===
|
|
# Actions supported by the CDP bridge / browser extension
|
|
extension_actions = {
|
|
"navigate", "navigate_back", "reload", "url", "title", "content",
|
|
"screenshot", "snapshot", "click", "dblclick", "hover",
|
|
"type", "fill", "clear", "press_key", "press",
|
|
"select_option", "check", "uncheck",
|
|
"evaluate", "wait", "wait_for_load",
|
|
"go_back", "go_forward", "get_url", "get_title", "get_tab_info",
|
|
"wait_for_navigation", "get_history", "create_tab", "close_tab",
|
|
"fetch",
|
|
"get_html", "set_html", "get_text", "set_text",
|
|
"get_attribute", "set_attribute", "remove_attribute",
|
|
"set_style", "add_class", "remove_class",
|
|
"insert_element", "remove_element",
|
|
"wait_for_selector", "query_selector_all",
|
|
"get_element_info", "get_page_info",
|
|
"observe_mutations", "computed_styles", "bounding_rects",
|
|
"inject_script", "inject_css",
|
|
"local_storage", "cookies",
|
|
"tabs", "new_tab", "select_tab",
|
|
# Multi-browser routing — persist a default-browser pick. Without
|
|
# one the extension auto-prefers firefox > safari > edge > chrome.
|
|
# (list_browsers/browsers are answered locally from the zapd
|
|
# provider list above, not routed to a provider.)
|
|
"set_default_browser", "use_browser",
|
|
"console", "network_requests", "status",
|
|
# Takeover actions (Phase 3)
|
|
"takeover", "release",
|
|
}
|
|
|
|
backend = self.backend
|
|
# Resolve browser filter from backend preference. Per-call override
|
|
# (target_browser) wins over global backend so a single MCP session
|
|
# can address Chrome and Firefox at different moments.
|
|
browser_filter = (target_browser or
|
|
(backend if backend in ("firefox", "chrome") else None))
|
|
|
|
# Skip extension entirely for "playwright" backend
|
|
use_extension = backend != "playwright" and action in extension_actions
|
|
|
|
if use_extension:
|
|
ext_result = await _extension_command(
|
|
action,
|
|
browser=browser_filter,
|
|
tab_id=tab_id,
|
|
client_id=client_id,
|
|
url=url,
|
|
selector=sel,
|
|
text=text,
|
|
value=value,
|
|
code=code,
|
|
expression=code,
|
|
full_page=full_page,
|
|
key=key,
|
|
index=index,
|
|
tab_index=tab_index,
|
|
timeout=timeout,
|
|
state=state,
|
|
level=level,
|
|
)
|
|
if ext_result is not None and "error" not in ext_result:
|
|
# Build normalized response from extension bridge result
|
|
resp: dict[str, Any] = {"success": True, "source": "extension"}
|
|
if isinstance(ext_result, dict):
|
|
resp.update(ext_result)
|
|
else:
|
|
resp["result"] = ext_result
|
|
return resp
|
|
|
|
# For explicit backends (firefox/chrome/extension), don't fall back to Playwright
|
|
if backend in ("firefox", "chrome", "extension"):
|
|
error_detail = ""
|
|
if ext_result and "error" in ext_result:
|
|
error_detail = f": {ext_result['error']}"
|
|
return {
|
|
"error": (f"Browser backend '{backend}' not available{error_detail}. "
|
|
f"Ensure the Hanzo extension is installed and connected."),
|
|
"action": action,
|
|
"backend": backend,
|
|
}
|
|
|
|
# === FALL BACK TO PLAYWRIGHT ===
|
|
if not PLAYWRIGHT_AVAILABLE:
|
|
ext_connected = await _check_extension(browser=browser_filter)
|
|
if ext_connected:
|
|
msg = (f"Action '{action}' is not supported by the browser extension "
|
|
f"and Playwright is not installed for fallback. "
|
|
f"Install Playwright: pip install playwright && playwright install chromium")
|
|
else:
|
|
msg = ("Browser extension not connected and Playwright not installed. "
|
|
"Either connect the Hanzo browser extension (start CDP bridge server) "
|
|
"or install Playwright: pip install playwright && playwright install chromium")
|
|
return {"error": msg, "action": action}
|
|
|
|
pool = await BrowserPool.get_instance()
|
|
|
|
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),
|
|
"image": ToolImage.from_bytes(data, "image/png", alt="screenshot"),
|
|
}
|
|
|
|
elif action == "pdf":
|
|
data = await page.pdf()
|
|
out = _save_capture(data, "pdf", locals().get("path"))
|
|
return out
|
|
|
|
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 in current window")] = None,
|
|
tab_id: Annotated[
|
|
Optional[str],
|
|
Field(
|
|
description=(
|
|
"Target tab id. Accepts the targetId returned by the "
|
|
"'tabs' action (e.g. 'tab-1888868904') or a numeric tab "
|
|
"id. Required when many windows are open and the OS-active "
|
|
"tab is not the one you want — without this every action "
|
|
"is dispatched to whatever tab is currently focused."
|
|
)
|
|
),
|
|
] = None,
|
|
client_id: Annotated[
|
|
Optional[str],
|
|
Field(
|
|
description=(
|
|
"Target a specific extension client (e.g. one Firefox "
|
|
"instance vs another). Use the client_id returned by 'status'."
|
|
)
|
|
),
|
|
] = None,
|
|
target_browser: Annotated[
|
|
Optional[str],
|
|
Field(description="Browser provider to dispatch to: 'chrome' | 'firefox'"),
|
|
] = 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,
|
|
) -> Any:
|
|
"""Complete browser automation with full Playwright API surface area."""
|
|
# One way for images: route through the SAME converter BaseTool.register
|
|
# uses, so a screenshot's ToolImage becomes a native MCP ImageContent
|
|
# block the client SEES — never a 250K-char base64 blob flattened into
|
|
# JSON text (which overflows the agent context and wedges the run).
|
|
result = 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,
|
|
tab_id=tab_id,
|
|
client_id=client_id,
|
|
target_browser=target_browser,
|
|
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,
|
|
)
|
|
return _result_to_mcp(result)
|
|
|
|
|
|
def create_browser_tool(
|
|
headless: bool = True,
|
|
cdp_endpoint: Optional[str] = None,
|
|
backend: Optional[str] = None,
|
|
) -> BrowserTool:
|
|
"""Create a browser tool instance."""
|
|
return BrowserTool(headless=headless, cdp_endpoint=cdp_endpoint, backend=backend)
|
|
|
|
|
|
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()
|