mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
hanzo-tools-browser 0.5.6: three orthogonal tools (browser / cdp / playwright)
Decomplects the browser surface. Previously: BrowserTool with three
braided transports (ZAP, CDP bridge, Playwright) plus a CdpTool alias
that was just a name change. Now: three peer tools, one transport
strategy each, env-gated registration.
Tools (each default-on, each disable-able):
- `browser` — high-level action surface, auto-routes transport.
Disable: HANZO_BROWSER_TOOL_DISABLED=1
- `cdp` — raw Chrome DevTools Protocol method dispatch
(`method=`, `params=`). No Playwright fallback.
Disable: HANZO_CDP_TOOL_DISABLED=1
- `playwright` — same action surface as `browser` but pinned to
backend=playwright (no extension / CDP-bridge).
Disable: HANZO_PLAYWRIGHT_TOOL_DISABLED=1
Transport knobs stay orthogonal to tool selection:
- HANZO_ZAP_DISABLED=1 — don't auto-start the in-process ZAP server
- HANZO_CDP_BRIDGE_ENABLED=1 — opt back into the legacy HTTP bridge
- BROWSER_TRANSPORT=zap|http|auto
- BROWSER_BACKEND=firefox|chrome|extension|playwright|auto
Lifecycle (ZAP server + CDP bridge background threads) moved out of
__init__.py into lifecycle.py. __init__.py is now a pure re-export
surface that resolves TOOLS at import time based on env flags.
CdpTool is now a genuine peer of BrowserTool — method-oriented, not
action-oriented — replacing the alias-subclass from 0.5.5.
This commit is contained in:
@@ -1,24 +1,36 @@
|
||||
"""Browser automation tools for Hanzo AI.
|
||||
"""Browser automation tools — decomplected surface.
|
||||
|
||||
Two-process architecture (since 0.5.0):
|
||||
Three orthogonal MCP tools, all default-enabled, all disable-able by env:
|
||||
|
||||
[Browser ext] --ZAP/WS--> [hanzo-mcp (this Python pkg)] --stdio--> [Agent]
|
||||
* ``browser`` — high-level action surface. Auto-routes through the
|
||||
in-process ZAP server (browser extension), the legacy
|
||||
CDP HTTP bridge, or Playwright. Disable with
|
||||
``HANZO_BROWSER_TOOL_DISABLED=1``.
|
||||
|
||||
The hanzo-mcp process hosts a ZAP server on an OS-assigned ephemeral port
|
||||
and advertises it via mDNS under ``_hanzo._tcp.local.`` (HIP-0069).
|
||||
Browser extensions browse mDNS and connect directly. No node bridge is
|
||||
in the critical path, no well-known port pool, no lockfile arbitration.
|
||||
* ``cdp`` — raw Chrome DevTools Protocol method dispatch. Same
|
||||
transports as ``browser`` minus the Playwright fallback.
|
||||
Disable with ``HANZO_CDP_TOOL_DISABLED=1``.
|
||||
|
||||
Legacy HTTP bridge (cdp_bridge_server.py on :9223/9224) is retained as an
|
||||
opt-in fallback for non-ZAP MCP clients. Enable with
|
||||
``HANZO_CDP_BRIDGE_ENABLED=1``.
|
||||
* ``playwright`` — Playwright-pinned action surface. Same actions as
|
||||
``browser`` but never touches the extension or CDP
|
||||
bridge. Disable with ``HANZO_PLAYWRIGHT_TOOL_DISABLED=1``.
|
||||
|
||||
Independent transport knobs (orthogonal to which tools are surfaced):
|
||||
|
||||
* ``HANZO_ZAP_DISABLED=1`` — don't auto-start the ZAP server.
|
||||
* ``HANZO_CDP_BRIDGE_ENABLED=1`` — opt back into the legacy HTTP bridge.
|
||||
* ``BROWSER_TRANSPORT=zap|http|auto`` — pin transport (default ``auto``).
|
||||
* ``BROWSER_BACKEND=firefox|chrome|extension|playwright|auto`` — backend
|
||||
preference for ``browser``.
|
||||
|
||||
Lifecycle (ZAP server, CDP bridge threads) lives in ``lifecycle.py``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from mcp.server import FastMCP
|
||||
|
||||
@@ -34,6 +46,15 @@ from hanzo_tools.browser.browser_tool import (
|
||||
create_browser_tool,
|
||||
launch_browser_server,
|
||||
)
|
||||
from hanzo_tools.browser.cdp_tool import CdpTool
|
||||
from hanzo_tools.browser.playwright_tool import PlaywrightTool
|
||||
from hanzo_tools.browser.lifecycle import (
|
||||
CDP_BRIDGE_AVAILABLE,
|
||||
ensure_zap_server,
|
||||
start_cdp_bridge,
|
||||
stop_cdp_bridge,
|
||||
stop_zap_server,
|
||||
)
|
||||
from hanzo_tools.browser.zap_server import (
|
||||
ZapClient,
|
||||
ZapServer,
|
||||
@@ -42,57 +63,115 @@ from hanzo_tools.browser.zap_server import (
|
||||
shutdown_server,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CDP Bridge for browser extension integration (legacy, opt-in)
|
||||
try:
|
||||
if TYPE_CHECKING:
|
||||
from hanzo_tools.browser.cdp_bridge_server import (
|
||||
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
|
||||
CDPBridgeClient,
|
||||
CDPBridgeServer,
|
||||
)
|
||||
except ImportError:
|
||||
CDP_BRIDGE_AVAILABLE = False
|
||||
CDPBridgeServer = None
|
||||
CDPBridgeClient = None
|
||||
|
||||
# Global CDP bridge server instance (singleton, legacy path)
|
||||
_cdp_bridge_server: Optional["CDPBridgeServer"] = None
|
||||
_cdp_bridge_thread: Optional[threading.Thread] = None
|
||||
_cdp_bridge_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
# Re-export CDP-bridge classes when available (legacy callers).
|
||||
try:
|
||||
from hanzo_tools.browser.cdp_bridge_server import (
|
||||
CDPBridgeClient,
|
||||
CDPBridgeServer,
|
||||
)
|
||||
except ImportError: # pragma: no cover
|
||||
CDPBridgeClient = None # type: ignore[assignment]
|
||||
CDPBridgeServer = None # type: ignore[assignment]
|
||||
|
||||
# Global ZAP server lifecycle (canonical path)
|
||||
_zap_thread: Optional[threading.Thread] = None
|
||||
_zap_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_zap_started_event: Optional[threading.Event] = None
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CdpTool(BrowserTool):
|
||||
"""Alias of BrowserTool surfaced under the ``cdp`` name.
|
||||
|
||||
Same action surface and transport stack as ``browser`` — both call into
|
||||
the in-process ZAP server (canonical) and fall back to the legacy CDP
|
||||
HTTP bridge or Playwright. Exposed as a separate tool so callers that
|
||||
think in terms of *Chrome DevTools Protocol* can reach the same control
|
||||
surface under a recognizable name. There is one implementation; this
|
||||
class only changes ``name``.
|
||||
# === Tools registry — gated by env ====================================
|
||||
|
||||
def _env_disabled(*names: str) -> bool:
|
||||
return any(os.environ.get(n, "").lower() in ("1", "true", "yes") for n in names)
|
||||
|
||||
|
||||
def _resolve_tools() -> list[type[BaseTool]]:
|
||||
"""Build TOOLS list at import time based on env flags.
|
||||
|
||||
Each tool is independently disable-able. Default: all three on.
|
||||
"""
|
||||
tools: list[type[BaseTool]] = []
|
||||
|
||||
name = "cdp"
|
||||
if not _env_disabled("HANZO_BROWSER_TOOL_DISABLED"):
|
||||
tools.append(BrowserTool)
|
||||
if not _env_disabled("HANZO_CDP_TOOL_DISABLED"):
|
||||
tools.append(CdpTool)
|
||||
if not _env_disabled("HANZO_PLAYWRIGHT_TOOL_DISABLED"):
|
||||
tools.append(PlaywrightTool)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
# Tools list for entry point discovery
|
||||
TOOLS = [BrowserTool, CdpTool]
|
||||
TOOLS: list[type[BaseTool]] = _resolve_tools()
|
||||
|
||||
|
||||
# === Registration entry point ==========================================
|
||||
|
||||
def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""Register browser tools with the MCP server.
|
||||
|
||||
Starts the in-process ZAP server (unless ``HANZO_ZAP_DISABLED=1``) so
|
||||
the browser extension can discover this MCP via mDNS. The legacy CDP
|
||||
HTTP bridge (port 9223/9224) stays off by default — opt in with
|
||||
``HANZO_CDP_BRIDGE_ENABLED=1`` or ``cdp_bridge=True`` kwarg.
|
||||
|
||||
Which tools get registered is controlled by env flags:
|
||||
* HANZO_BROWSER_TOOL_DISABLED
|
||||
* HANZO_CDP_TOOL_DISABLED
|
||||
* HANZO_PLAYWRIGHT_TOOL_DISABLED
|
||||
"""
|
||||
headless = kwargs.get("headless", True)
|
||||
cdp_endpoint = kwargs.get("cdp_endpoint")
|
||||
backend = kwargs.get("backend")
|
||||
|
||||
# Canonical lifecycle: in-process ZAP server.
|
||||
if backend != "playwright":
|
||||
ensure_zap_server()
|
||||
|
||||
# Optional legacy lifecycle: CDP HTTP bridge.
|
||||
if kwargs.get(
|
||||
"cdp_bridge",
|
||||
os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() in ("1", "true", "yes"),
|
||||
) and CDP_BRIDGE_AVAILABLE and backend != "playwright":
|
||||
start_cdp_bridge()
|
||||
|
||||
registered: list[BaseTool] = []
|
||||
for tool_class in TOOLS:
|
||||
if tool_class is BrowserTool:
|
||||
tool = create_browser_tool(
|
||||
headless=headless, cdp_endpoint=cdp_endpoint, backend=backend
|
||||
)
|
||||
elif tool_class is PlaywrightTool:
|
||||
# PlaywrightTool forces backend internally; respect headless+endpoint.
|
||||
tool = PlaywrightTool(headless=headless, cdp_endpoint=cdp_endpoint)
|
||||
else:
|
||||
# CdpTool, future peers — no-arg constructor.
|
||||
tool = tool_class()
|
||||
ToolRegistry.register_tool(mcp_server, tool)
|
||||
registered.append(tool)
|
||||
return registered
|
||||
|
||||
|
||||
def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""Standard entry point called by tool-discovery hosts."""
|
||||
return register_browser_tools(mcp_server, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Main tool
|
||||
# Tools (the three peers)
|
||||
"BrowserTool",
|
||||
"CdpTool",
|
||||
"PlaywrightTool",
|
||||
# Factory + module-level instance (existing public API)
|
||||
"browser_tool",
|
||||
"create_browser_tool",
|
||||
# Browser pool
|
||||
"BrowserPool",
|
||||
"launch_browser_server",
|
||||
# ZAP (canonical, since 0.5.0)
|
||||
# ZAP (canonical)
|
||||
"ZapServer",
|
||||
"ZapClient",
|
||||
"get_or_start_server",
|
||||
@@ -104,223 +183,15 @@ __all__ = [
|
||||
"CDP_BRIDGE_AVAILABLE",
|
||||
"start_cdp_bridge",
|
||||
"stop_cdp_bridge",
|
||||
# Lifecycle (now in lifecycle.py)
|
||||
"ensure_zap_server",
|
||||
"stop_zap_server",
|
||||
# Availability check
|
||||
"PLAYWRIGHT_AVAILABLE",
|
||||
# Backend helper
|
||||
"get_backend",
|
||||
# Registration
|
||||
"TOOLS",
|
||||
"register_browser_tools",
|
||||
"register_tools",
|
||||
]
|
||||
|
||||
|
||||
def _run_zap_server(host: str) -> None:
|
||||
"""Run the ZAP server in a dedicated background thread.
|
||||
|
||||
The MCP's main loop is owned by FastMCP/stdio; we keep ZAP isolated so
|
||||
its websockets server has its own asyncio event loop and never fights
|
||||
with stdio for cycles. The thread lives for the MCP's lifetime.
|
||||
"""
|
||||
global _zap_loop, _zap_started_event
|
||||
|
||||
_zap_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_zap_loop)
|
||||
|
||||
async def _bootstrap() -> None:
|
||||
srv = await get_or_start_server(
|
||||
host=host,
|
||||
agent_label=os.environ.get("HANZO_AGENT_LABEL"),
|
||||
)
|
||||
if _zap_started_event is not None:
|
||||
_zap_started_event.set()
|
||||
if srv is None:
|
||||
return
|
||||
# Idle forever; the websockets server owns its own tasks.
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
try:
|
||||
_zap_loop.run_until_complete(_bootstrap())
|
||||
except Exception as e:
|
||||
logger.error("ZAP server thread crashed: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def _ensure_zap_server() -> bool:
|
||||
"""Start the in-process ZAP server if not already running.
|
||||
|
||||
Returns True if the server is alive after this call.
|
||||
"""
|
||||
global _zap_thread, _zap_started_event
|
||||
|
||||
if _zap_thread is not None and _zap_thread.is_alive():
|
||||
return get_server() is not None
|
||||
|
||||
if os.environ.get("HANZO_ZAP_DISABLED", "").lower() in ("1", "true", "yes"):
|
||||
return False
|
||||
|
||||
host = os.environ.get("HANZO_ZAP_HOST", "127.0.0.1")
|
||||
|
||||
_zap_started_event = threading.Event()
|
||||
_zap_thread = threading.Thread(
|
||||
target=_run_zap_server,
|
||||
args=(host,),
|
||||
daemon=True,
|
||||
name="hanzo-zap-server",
|
||||
)
|
||||
_zap_thread.start()
|
||||
|
||||
# Wait for bind result so callers see a determinate state.
|
||||
_zap_started_event.wait(timeout=2.0)
|
||||
return get_server() is not None
|
||||
|
||||
|
||||
def stop_zap_server() -> None:
|
||||
"""Stop the in-process ZAP server (best-effort, non-blocking)."""
|
||||
global _zap_thread, _zap_loop, _zap_started_event
|
||||
|
||||
if _zap_loop is not None:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(shutdown_server(), _zap_loop)
|
||||
except Exception:
|
||||
pass
|
||||
_zap_loop = None
|
||||
_zap_thread = None
|
||||
_zap_started_event = None
|
||||
|
||||
|
||||
def _run_cdp_bridge_server(host: str, port: int) -> None:
|
||||
"""Run CDP bridge server in a background thread."""
|
||||
global _cdp_bridge_server, _cdp_bridge_loop
|
||||
|
||||
_cdp_bridge_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_cdp_bridge_loop)
|
||||
|
||||
_cdp_bridge_server = CDPBridgeServer(host=host, port=port)
|
||||
|
||||
async def run():
|
||||
await _cdp_bridge_server.start()
|
||||
# Keep running until stopped
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
_cdp_bridge_loop.run_until_complete(run())
|
||||
except Exception as e:
|
||||
logger.error(f"CDP bridge server crashed: {e}", exc_info=True)
|
||||
|
||||
|
||||
def start_cdp_bridge(
|
||||
host: str = "localhost",
|
||||
port: int = 9223,
|
||||
auto_start: bool = True,
|
||||
) -> bool:
|
||||
"""Start the CDP bridge server for browser extension integration.
|
||||
|
||||
The CDP bridge enables communication between:
|
||||
- hanzo-mcp's browser tool (via HTTP API on port 9224)
|
||||
- Hanzo browser extension (via WebSocket on port 9223)
|
||||
|
||||
Args:
|
||||
host: Host to bind to (default: localhost)
|
||||
port: WebSocket port (default: 9223, HTTP API on 9224)
|
||||
auto_start: Whether to auto-start (can be disabled via env var)
|
||||
|
||||
Returns:
|
||||
True if bridge started, False otherwise
|
||||
"""
|
||||
global _cdp_bridge_thread
|
||||
|
||||
# Check if disabled via environment
|
||||
if os.environ.get("HANZO_CDP_BRIDGE_DISABLED", "").lower() in ("1", "true", "yes"):
|
||||
logger.debug("CDP bridge disabled via environment")
|
||||
return False
|
||||
|
||||
if not CDP_BRIDGE_AVAILABLE:
|
||||
logger.debug("CDP bridge not available (websockets not installed)")
|
||||
return False
|
||||
|
||||
if _cdp_bridge_thread is not None and _cdp_bridge_thread.is_alive():
|
||||
logger.debug("CDP bridge already running")
|
||||
return True
|
||||
|
||||
# Override from environment
|
||||
host = os.environ.get("HANZO_CDP_BRIDGE_HOST", host)
|
||||
port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", str(port)))
|
||||
|
||||
try:
|
||||
_cdp_bridge_thread = threading.Thread(
|
||||
target=_run_cdp_bridge_server,
|
||||
args=(host, port),
|
||||
daemon=True,
|
||||
name="cdp-bridge-server",
|
||||
)
|
||||
_cdp_bridge_thread.start()
|
||||
logger.info(f"CDP bridge started on ws://{host}:{port}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to start CDP bridge: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def stop_cdp_bridge() -> None:
|
||||
"""Stop the CDP bridge server."""
|
||||
global _cdp_bridge_server, _cdp_bridge_thread, _cdp_bridge_loop
|
||||
|
||||
if _cdp_bridge_loop is not None:
|
||||
try:
|
||||
if _cdp_bridge_server is not None:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_cdp_bridge_server.stop(), _cdp_bridge_loop
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_cdp_bridge_server = None
|
||||
_cdp_bridge_thread = None
|
||||
_cdp_bridge_loop = None
|
||||
|
||||
|
||||
def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""Register browser tools with the MCP server.
|
||||
|
||||
Auto-starts the ZAP server (canonical path) so the Hanzo browser
|
||||
extension can discover this MCP and dispatch actions to it directly.
|
||||
The legacy CDP bridge (port 9223/9224) is opt-in via
|
||||
``HANZO_CDP_BRIDGE_ENABLED=1`` or ``cdp_bridge=True`` kwarg.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
**kwargs: Additional arguments (headless, cdp_endpoint, backend, cdp_bridge)
|
||||
|
||||
Returns:
|
||||
List of registered tools
|
||||
"""
|
||||
headless = kwargs.get("headless", True)
|
||||
cdp_endpoint = kwargs.get("cdp_endpoint")
|
||||
backend = kwargs.get("backend")
|
||||
|
||||
# Canonical path: in-process ZAP server.
|
||||
if backend != "playwright":
|
||||
_ensure_zap_server()
|
||||
|
||||
# Legacy CDP bridge (opt-in only).
|
||||
enable_cdp_bridge = kwargs.get(
|
||||
"cdp_bridge",
|
||||
os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() in ("1", "true", "yes"),
|
||||
)
|
||||
if enable_cdp_bridge and CDP_BRIDGE_AVAILABLE and backend != "playwright":
|
||||
start_cdp_bridge()
|
||||
|
||||
tool = create_browser_tool(
|
||||
headless=headless, cdp_endpoint=cdp_endpoint, backend=backend
|
||||
)
|
||||
ToolRegistry.register_tool(mcp_server, tool)
|
||||
return [tool]
|
||||
|
||||
|
||||
def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""Register all browser tools with the MCP server.
|
||||
|
||||
This is the standard entry point called by the tool discovery system.
|
||||
Auto-starts CDP bridge for browser extension integration.
|
||||
"""
|
||||
return register_browser_tools(mcp_server, **kwargs)
|
||||
|
||||
@@ -840,38 +840,27 @@ class BrowserTool(BaseTool):
|
||||
self.backend = backend or get_backend()
|
||||
self.timeout = 30000
|
||||
|
||||
# Auto-start the in-process ZAP server (canonical, since 0.5.0). One
|
||||
# MCP = one ZAP server bound to the lowest free port from
|
||||
# 9999..9995. The browser extension discovers it directly — no node
|
||||
# bridge needed in the critical path.
|
||||
if self.backend != "playwright" and os.environ.get("HANZO_ZAP_DISABLED", "").lower() not in ("1", "true", "yes"):
|
||||
# Lifecycle (ZAP server, CDP bridge) lives in `lifecycle.py`.
|
||||
# Import lazily to avoid a circular import on package load —
|
||||
# __init__.py already pulls in this module before lifecycle.
|
||||
if self.backend != "playwright":
|
||||
try:
|
||||
# Resolve lazily via importlib to avoid circular import on
|
||||
# package load (hanzo_tools.browser imports BrowserTool at
|
||||
# top level, but this constructor needs the bootstrap helper
|
||||
# defined later in that same package).
|
||||
import importlib
|
||||
from hanzo_tools.browser.lifecycle import (
|
||||
CDP_BRIDGE_AVAILABLE,
|
||||
ensure_zap_server,
|
||||
start_cdp_bridge,
|
||||
)
|
||||
|
||||
pkg = importlib.import_module("hanzo_tools.browser")
|
||||
ensure = getattr(pkg, "_ensure_zap_server", None)
|
||||
if ensure is not None:
|
||||
ensure()
|
||||
except Exception as e:
|
||||
logger.debug("zap server bootstrap failed: %s", e)
|
||||
ensure_zap_server() # idempotent + respects HANZO_ZAP_DISABLED
|
||||
|
||||
# Legacy HTTP bridge (CDP bridge): kept as fallback transport for
|
||||
# non-ZAP MCP clients. Not auto-started anymore — ZAP is canonical.
|
||||
# Set HANZO_CDP_BRIDGE_ENABLED=1 to opt back in.
|
||||
if (
|
||||
self.backend != "playwright"
|
||||
and os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower() in ("1", "true", "yes")
|
||||
):
|
||||
try:
|
||||
from hanzo_tools.browser import CDP_BRIDGE_AVAILABLE, start_cdp_bridge
|
||||
if CDP_BRIDGE_AVAILABLE:
|
||||
if (
|
||||
os.environ.get("HANZO_CDP_BRIDGE_ENABLED", "").lower()
|
||||
in ("1", "true", "yes")
|
||||
and CDP_BRIDGE_AVAILABLE
|
||||
):
|
||||
start_cdp_bridge()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("browser lifecycle bootstrap failed: %s", e)
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Raw Chrome DevTools Protocol method dispatch.
|
||||
|
||||
Decomplected from BrowserTool: `browser` is *action-oriented* (high-level
|
||||
verbs like `navigate`, `click`); `cdp` is *method-oriented* (sends a CDP
|
||||
method by name with raw params). Same backing transports (in-process ZAP
|
||||
server → legacy HTTP bridge), no Playwright fallback — for that, use
|
||||
`browser` or `playwright`.
|
||||
|
||||
Use this tool when you need a CDP method the high-level surface doesn't
|
||||
expose, want to inspect raw protocol responses, or are wiring something
|
||||
to the protocol directly.
|
||||
|
||||
Example::
|
||||
|
||||
cdp(action="send", method="Page.navigate", params={"url": "https://example.com"})
|
||||
cdp(action="send", method="Runtime.evaluate", params={"expression": "1+1"})
|
||||
cdp(action="tabs") # list connected tabs (Target.getTargets)
|
||||
cdp(action="status") # connection status
|
||||
cdp(action="list_browsers") # which providers (firefox/chrome/safari) are connected
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _dispatch_raw(
|
||||
method: str,
|
||||
params: Optional[dict] = None,
|
||||
*,
|
||||
browser: Optional[str] = None,
|
||||
tab_id: Optional[Union[str, int]] = None,
|
||||
client_id: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
) -> dict:
|
||||
"""Dispatch a raw CDP method to the connected browser provider.
|
||||
|
||||
Path order — same as BrowserTool:
|
||||
1. In-process ZAP server (microsecond round-trip; preferred).
|
||||
2. Legacy HTTP bridge on :9224 (kept as fallback for non-ZAP clients).
|
||||
|
||||
Pin transport via ``BROWSER_TRANSPORT=zap|http|auto`` (default ``auto``).
|
||||
"""
|
||||
params = dict(params or {})
|
||||
|
||||
# Normalize tab id (accept "tab-123" or 123 or "123")
|
||||
if tab_id is not None:
|
||||
t = tab_id
|
||||
if isinstance(t, str) and t.startswith("tab-"):
|
||||
t = t[4:]
|
||||
try:
|
||||
t = int(t)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
params.setdefault("tabId", t)
|
||||
|
||||
transport = os.environ.get("BROWSER_TRANSPORT", "auto").strip().lower()
|
||||
if transport not in {"zap", "http", "auto"}:
|
||||
transport = "auto"
|
||||
|
||||
# 1) ZAP path
|
||||
if transport in {"zap", "auto"}:
|
||||
try:
|
||||
from hanzo_tools.browser.zap_server import get_server
|
||||
|
||||
srv = get_server()
|
||||
if srv is not None and srv.has_client(browser=browser):
|
||||
try:
|
||||
raw = await srv.send(
|
||||
method, params, browser=browser, client_id=client_id
|
||||
)
|
||||
return {"success": True, "transport": "zap", "method": method, "result": raw}
|
||||
except Exception as e:
|
||||
if transport == "zap":
|
||||
return {"error": str(e), "transport": "zap", "method": method}
|
||||
logger.debug("zap dispatch failed, falling back to http: %s", e)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if transport == "zap":
|
||||
return {
|
||||
"error": "ZAP transport selected but no extension client matched",
|
||||
"transport": "zap",
|
||||
"method": method,
|
||||
}
|
||||
|
||||
# 2) HTTP fallback — legacy CDP bridge speaks raw CDP via a `cdp` action
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
payload: dict[str, Any] = {"action": "cdp", "method": method, "params": params}
|
||||
if browser:
|
||||
payload["browser"] = browser
|
||||
if client_id:
|
||||
payload["clientId"] = client_id
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
"http://localhost:9224",
|
||||
json=payload,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as resp:
|
||||
body = await resp.text()
|
||||
try:
|
||||
import json
|
||||
|
||||
parsed = json.loads(body)
|
||||
except Exception:
|
||||
parsed = {"raw": body}
|
||||
parsed.setdefault("transport", "http")
|
||||
parsed.setdefault("method", method)
|
||||
if resp.status == 200:
|
||||
parsed.setdefault("success", True)
|
||||
else:
|
||||
parsed.setdefault("status", resp.status)
|
||||
parsed.setdefault("error", parsed.get("error") or body[:200])
|
||||
return parsed
|
||||
except Exception as e:
|
||||
logger.debug("CDP dispatch HTTP fallback failed: %s", e)
|
||||
return {"error": str(e), "transport": "http", "method": method}
|
||||
|
||||
|
||||
class CdpTool(BaseTool):
|
||||
"""Raw Chrome DevTools Protocol method dispatch.
|
||||
|
||||
Peer of ``browser`` (action-oriented) and ``playwright`` (Playwright API).
|
||||
Sends any CDP method directly to a connected browser via the ZAP server
|
||||
(extension) or legacy CDP HTTP bridge. Does NOT fall back to Playwright.
|
||||
"""
|
||||
|
||||
name = "cdp"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return """Raw Chrome DevTools Protocol dispatch — peer of `browser`.
|
||||
|
||||
ACTIONS:
|
||||
- send : send a CDP method (method=, params=, tab_id=, target_browser=)
|
||||
- tabs : Target.getTargets — list connected tabs
|
||||
- status : Browser.getVersion — connection + version
|
||||
- list_browsers : list extension providers (firefox/chrome/safari/edge) connected
|
||||
- claim_browser / release_browser : exclusive-lease management
|
||||
|
||||
EXAMPLES:
|
||||
- cdp(action="send", method="Page.navigate", params={"url": "https://example.com"})
|
||||
- cdp(action="send", method="Runtime.evaluate", params={"expression": "document.title"})
|
||||
- cdp(action="tabs")
|
||||
- cdp(action="status")
|
||||
|
||||
Use `browser` for high-level verbs (navigate, click, screenshot).
|
||||
Use `playwright` for headless Playwright automation.
|
||||
"""
|
||||
|
||||
async def call(self, ctx, action: str = "send", **kwargs) -> dict[str, Any]:
|
||||
return await self.execute(action=action, **kwargs)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
action: str = "send",
|
||||
# Raw CDP
|
||||
method: Optional[str] = None,
|
||||
params: Optional[dict] = None,
|
||||
# Routing
|
||||
tab_id: Optional[Union[str, int]] = None,
|
||||
target_browser: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
# Timeout
|
||||
timeout: Optional[float] = None,
|
||||
) -> dict[str, Any]:
|
||||
t = float(timeout) if timeout else 30.0
|
||||
|
||||
# === Local actions (handled in-process) =====================
|
||||
if action == "list_browsers":
|
||||
try:
|
||||
from hanzo_tools.browser.zap_server import get_server
|
||||
|
||||
srv = get_server()
|
||||
if srv is None:
|
||||
return {"error": "zap server not running"}
|
||||
clients = []
|
||||
for c in srv.clients:
|
||||
clients.append(
|
||||
{
|
||||
"client_id": c.client_id,
|
||||
"browser": getattr(c, "browser", None),
|
||||
"label": getattr(c, "label", None),
|
||||
}
|
||||
)
|
||||
return {"success": True, "browsers": clients, "count": len(clients)}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
if action == "claim_browser":
|
||||
try:
|
||||
from hanzo_tools.browser.zap_server import DEFAULT_LEASE_TTL, get_server
|
||||
|
||||
srv = get_server()
|
||||
if srv is None:
|
||||
return {"error": "zap server not running"}
|
||||
client = srv.resolve_client(client_id=client_id, browser=target_browser)
|
||||
if client is None:
|
||||
return {"error": "no matching extension client"}
|
||||
lease = srv.claim(client.client_id, ttl=t)
|
||||
return {
|
||||
"success": True,
|
||||
"client_id": lease.client_id,
|
||||
"holder": lease.holder,
|
||||
"expires_at": lease.expires_at,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
if action == "release_browser":
|
||||
try:
|
||||
from hanzo_tools.browser.zap_server import get_server
|
||||
|
||||
srv = get_server()
|
||||
if srv is None:
|
||||
return {"error": "zap server not running"}
|
||||
if client_id:
|
||||
return {"success": srv.release(client_id), "client_id": client_id}
|
||||
released = [c.client_id for c in list(srv.clients) if srv.release(c.client_id)]
|
||||
return {"success": True, "released": released}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# === Sugared CDP methods ==================================
|
||||
sugared = {
|
||||
"tabs": ("Target.getTargets", {}),
|
||||
"status": ("Browser.getVersion", {}),
|
||||
}
|
||||
if action in sugared:
|
||||
m, p = sugared[action]
|
||||
return await _dispatch_raw(
|
||||
m,
|
||||
p,
|
||||
browser=target_browser,
|
||||
tab_id=tab_id,
|
||||
client_id=client_id,
|
||||
timeout=t,
|
||||
)
|
||||
|
||||
# === Raw send ==============================================
|
||||
if action == "send":
|
||||
if not method:
|
||||
return {
|
||||
"error": "method required for action=send (e.g. 'Page.navigate')",
|
||||
"action": "send",
|
||||
}
|
||||
return await _dispatch_raw(
|
||||
method,
|
||||
params,
|
||||
browser=target_browser,
|
||||
tab_id=tab_id,
|
||||
client_id=client_id,
|
||||
timeout=t,
|
||||
)
|
||||
|
||||
return {
|
||||
"error": f"unknown action '{action}'. Try: send, tabs, status, list_browsers, claim_browser, release_browser",
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Lifecycle helpers for the browser package: ZAP server + legacy CDP bridge.
|
||||
|
||||
Decomplected out of ``__init__.py`` so the package's namespace is a pure
|
||||
re-export surface. The MCP server (or any host) imports ``_ensure_zap_server``
|
||||
and ``start_cdp_bridge`` from here when it wants the long-lived background
|
||||
threads bound. Tools themselves never touch lifecycle directly.
|
||||
|
||||
Two background lifecycles, isolated:
|
||||
|
||||
* ZAP server — canonical. One MCP = one ZAP server bound to the lowest
|
||||
free port from 9999..9995. Browser extension discovers
|
||||
it via mDNS. Lifetime = MCP lifetime.
|
||||
|
||||
* CDP bridge — legacy HTTP fallback on :9223/:9224 for non-ZAP clients.
|
||||
Opt-in: ``HANZO_CDP_BRIDGE_ENABLED=1``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hanzo_tools.browser.cdp_bridge_server import CDPBridgeServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CDP bridge availability check
|
||||
try:
|
||||
from hanzo_tools.browser.cdp_bridge_server import (
|
||||
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
|
||||
CDPBridgeServer,
|
||||
)
|
||||
except ImportError: # pragma: no cover
|
||||
CDP_BRIDGE_AVAILABLE = False
|
||||
CDPBridgeServer = None # type: ignore[assignment]
|
||||
|
||||
# === Global state (one of each per process) ============================
|
||||
|
||||
_zap_thread: Optional[threading.Thread] = None
|
||||
_zap_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_zap_started_event: Optional[threading.Event] = None
|
||||
|
||||
_cdp_bridge_server: Optional["CDPBridgeServer"] = None
|
||||
_cdp_bridge_thread: Optional[threading.Thread] = None
|
||||
_cdp_bridge_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
|
||||
# === ZAP (canonical) ====================================================
|
||||
|
||||
|
||||
def _run_zap_server(host: str) -> None:
|
||||
"""Run the ZAP server in a dedicated background thread."""
|
||||
global _zap_loop, _zap_started_event
|
||||
|
||||
from hanzo_tools.browser.zap_server import get_or_start_server
|
||||
|
||||
_zap_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_zap_loop)
|
||||
|
||||
async def _bootstrap() -> None:
|
||||
srv = await get_or_start_server(
|
||||
host=host,
|
||||
agent_label=os.environ.get("HANZO_AGENT_LABEL"),
|
||||
)
|
||||
if _zap_started_event is not None:
|
||||
_zap_started_event.set()
|
||||
if srv is None:
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
try:
|
||||
_zap_loop.run_until_complete(_bootstrap())
|
||||
except Exception as e:
|
||||
logger.error("ZAP server thread crashed: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def ensure_zap_server() -> bool:
|
||||
"""Start the in-process ZAP server if not already running.
|
||||
|
||||
Returns True if the server is alive after this call.
|
||||
Idempotent — safe to call multiple times.
|
||||
"""
|
||||
global _zap_thread, _zap_started_event
|
||||
|
||||
if _zap_thread is not None and _zap_thread.is_alive():
|
||||
from hanzo_tools.browser.zap_server import get_server
|
||||
|
||||
return get_server() is not None
|
||||
|
||||
if os.environ.get("HANZO_ZAP_DISABLED", "").lower() in ("1", "true", "yes"):
|
||||
return False
|
||||
|
||||
host = os.environ.get("HANZO_ZAP_HOST", "127.0.0.1")
|
||||
|
||||
_zap_started_event = threading.Event()
|
||||
_zap_thread = threading.Thread(
|
||||
target=_run_zap_server,
|
||||
args=(host,),
|
||||
daemon=True,
|
||||
name="hanzo-zap-server",
|
||||
)
|
||||
_zap_thread.start()
|
||||
_zap_started_event.wait(timeout=2.0)
|
||||
|
||||
from hanzo_tools.browser.zap_server import get_server
|
||||
|
||||
return get_server() is not None
|
||||
|
||||
|
||||
def stop_zap_server() -> None:
|
||||
"""Stop the in-process ZAP server (best-effort, non-blocking)."""
|
||||
global _zap_thread, _zap_loop, _zap_started_event
|
||||
|
||||
if _zap_loop is not None:
|
||||
try:
|
||||
from hanzo_tools.browser.zap_server import shutdown_server
|
||||
|
||||
asyncio.run_coroutine_threadsafe(shutdown_server(), _zap_loop)
|
||||
except Exception:
|
||||
pass
|
||||
_zap_loop = None
|
||||
_zap_thread = None
|
||||
_zap_started_event = None
|
||||
|
||||
|
||||
# === CDP bridge (legacy) ===============================================
|
||||
|
||||
|
||||
def _run_cdp_bridge_server(host: str, port: int) -> None:
|
||||
"""Run CDP bridge server in a background thread."""
|
||||
global _cdp_bridge_server, _cdp_bridge_loop
|
||||
|
||||
_cdp_bridge_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_cdp_bridge_loop)
|
||||
|
||||
_cdp_bridge_server = CDPBridgeServer(host=host, port=port) # type: ignore[misc]
|
||||
|
||||
async def run() -> None:
|
||||
await _cdp_bridge_server.start() # type: ignore[union-attr]
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
_cdp_bridge_loop.run_until_complete(run())
|
||||
except Exception as e:
|
||||
logger.error("CDP bridge server crashed: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_cdp_bridge(host: str = "localhost", port: int = 9223) -> bool:
|
||||
"""Start the legacy CDP bridge server (opt-in fallback transport).
|
||||
|
||||
Enables HTTP communication between hanzo-mcp's tools (port 9224) and
|
||||
the Hanzo browser extension (WebSocket on `port`, default 9223).
|
||||
Set ``HANZO_CDP_BRIDGE_DISABLED=1`` to refuse to start.
|
||||
"""
|
||||
global _cdp_bridge_thread
|
||||
|
||||
if os.environ.get("HANZO_CDP_BRIDGE_DISABLED", "").lower() in ("1", "true", "yes"):
|
||||
return False
|
||||
if not CDP_BRIDGE_AVAILABLE:
|
||||
return False
|
||||
if _cdp_bridge_thread is not None and _cdp_bridge_thread.is_alive():
|
||||
return True
|
||||
|
||||
host = os.environ.get("HANZO_CDP_BRIDGE_HOST", host)
|
||||
port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", str(port)))
|
||||
|
||||
try:
|
||||
_cdp_bridge_thread = threading.Thread(
|
||||
target=_run_cdp_bridge_server,
|
||||
args=(host, port),
|
||||
daemon=True,
|
||||
name="cdp-bridge-server",
|
||||
)
|
||||
_cdp_bridge_thread.start()
|
||||
logger.info("CDP bridge started on ws://%s:%d", host, port)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start CDP bridge: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def stop_cdp_bridge() -> None:
|
||||
"""Stop the CDP bridge server."""
|
||||
global _cdp_bridge_server, _cdp_bridge_thread, _cdp_bridge_loop
|
||||
|
||||
if _cdp_bridge_loop is not None and _cdp_bridge_server is not None:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
_cdp_bridge_server.stop(), _cdp_bridge_loop
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_cdp_bridge_server = None
|
||||
_cdp_bridge_thread = None
|
||||
_cdp_bridge_loop = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CDP_BRIDGE_AVAILABLE",
|
||||
"ensure_zap_server",
|
||||
"stop_zap_server",
|
||||
"start_cdp_bridge",
|
||||
"stop_cdp_bridge",
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Playwright-pinned browser tool.
|
||||
|
||||
Same action surface as ``BrowserTool`` but with ``backend="playwright"``
|
||||
forced — never dispatches through the extension or CDP bridge. Use this
|
||||
when you want deterministic headless automation regardless of whether a
|
||||
browser extension is connected to this MCP.
|
||||
|
||||
The high-level ``browser`` tool auto-detects transport. Calling
|
||||
``playwright`` is the explicit "I want Playwright, period" path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from hanzo_tools.browser.browser_tool import BrowserTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaywrightTool(BrowserTool):
|
||||
"""Browser automation pinned to the Playwright backend.
|
||||
|
||||
Skips the in-process ZAP server (extension dispatch) and the legacy
|
||||
CDP HTTP bridge entirely — every action goes through Playwright's
|
||||
Chromium driver. Suitable for headless test runs, CI pipelines, and
|
||||
any context where attaching to a user-controlled browser is wrong.
|
||||
|
||||
The action surface (navigate, click, fill, screenshot, expect_*, ...)
|
||||
is identical to ``BrowserTool`` — only the transport pin differs.
|
||||
"""
|
||||
|
||||
name = "playwright"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
headless: bool = True,
|
||||
cdp_endpoint: Optional[str] = None,
|
||||
):
|
||||
# Force backend="playwright" — overrides BROWSER_BACKEND env, ignores
|
||||
# extension config, skips ZAP/CDP-bridge lifecycle bootstrap.
|
||||
super().__init__(
|
||||
headless=headless,
|
||||
cdp_endpoint=cdp_endpoint,
|
||||
backend="playwright",
|
||||
)
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return """Playwright-pinned browser automation — peer of `browser` and `cdp`.
|
||||
|
||||
Same action surface as `browser` but forces backend="playwright":
|
||||
- No extension dispatch (does not talk to Hanzo browser extension).
|
||||
- No legacy CDP HTTP bridge.
|
||||
- Pure async-playwright, headless by default.
|
||||
|
||||
Use `browser` for auto-routing (extension > CDP-bridge > Playwright).
|
||||
Use `cdp` for raw Chrome DevTools Protocol method dispatch.
|
||||
Use `playwright` for deterministic headless automation.
|
||||
""" + BrowserTool.description.fget(self).split("CATEGORIES:", 1)[-1].rstrip()
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-browser"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
description = "Browser automation tools with ZAP-native extension routing — Python MCP hosts the ZAP server on an OS-assigned port and advertises via mDNS (HIP-0069). No node bridge, no port pool, no lockfile."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Reference in New Issue
Block a user