mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(tools-browser): ZAP-native 2-process architecture (0.5.0)
Each hanzo-mcp now hosts a ZAP server directly on the lowest free port from [9999..9995], using a POSIX flock for cross-MCP arbitration. The browser extension discovers and connects to this server without any node-side bridge in the critical path. - New hanzo_tools/browser/zap_server.py: wire format, server, lifecycle, client registry, leases, cluster-visibility config registry. - _extension_command tries ZAP first (sub-1ms median round-trip on loopback), falls back to legacy HTTP bridge on :9224 only when no ZAP-connected client matches. Pinnable via BROWSER_TRANSPORT. - New tool actions: list_mcp_instances, claim_browser, release_browser for sub-agent coordination across multiple parallel MCPs. - Legacy CDP HTTP bridge no longer auto-starts (opt in with HANZO_CDP_BRIDGE_ENABLED=1). It remains available for non-ZAP MCP clients. Tests: 30 new unit tests cover wire format, port arbitration, multi-MCP coexistence, lease semantics, and cluster registry. Bench shows 0.35ms median round-trip on local loopback. Total tests: 4 baseline -> 35 passing (30 zap_server + 1 bench + 4 imports).
This commit is contained in:
@@ -95,6 +95,49 @@ SQLite-based with optional vector search (sqlite-vec).
|
||||
|
||||
**Backends:** local (default), sqlite, lancedb (optional), kuzudb (optional)
|
||||
|
||||
## Browser Tool (hanzo-tools-browser)
|
||||
|
||||
Two-process architecture (since 0.5.0): `hanzo-tools-browser` hosts the
|
||||
ZAP server directly inside the MCP process. Browser extensions discover
|
||||
it on the lowest free port from `[9999, 9998, 9997, 9996, 9995]` (POSIX
|
||||
flock ensures multi-MCP coexistence under one ext).
|
||||
|
||||
```python
|
||||
from hanzo_tools.browser import (
|
||||
BrowserTool, # the MCP tool
|
||||
ZapServer, # raw server (for advanced use)
|
||||
get_or_start_server, # bootstrap + return singleton
|
||||
get_server, # return current singleton or None
|
||||
)
|
||||
```
|
||||
|
||||
Key files:
|
||||
- `hanzo_tools/browser/zap_server.py` — wire format, server, leases,
|
||||
cluster registry (`~/.hanzo/extension/config.json:mcp_instances`).
|
||||
- `hanzo_tools/browser/browser_tool.py` — `_extension_command` tries
|
||||
ZAP first, falls back to legacy HTTP bridge on `:9224`. New actions
|
||||
`list_mcp_instances`, `claim_browser`, `release_browser`.
|
||||
- `hanzo_tools/browser/cdp_bridge_server.py` — legacy node-bridge
|
||||
replacement (kept for non-ZAP callers).
|
||||
|
||||
Env vars:
|
||||
- `BROWSER_TRANSPORT=zap|http|auto` — pin transport (default auto).
|
||||
- `HANZO_ZAP_DISABLED=1` — don't auto-start the ZAP server.
|
||||
- `HANZO_ZAP_PORTS=9999,9998` — override the candidate port list.
|
||||
- `HANZO_AGENT_LABEL=...` — attach to the cluster registry entry.
|
||||
- `HANZO_CDP_BRIDGE_ENABLED=1` — opt back into the legacy HTTP bridge.
|
||||
|
||||
Tests live in `pkg/hanzo-tools-browser/tests/test_zap_server.py` (30
|
||||
cases: wire format, lifecycle, RPC, leases, multi-MCP). Latency bench
|
||||
in `test_zap_bench.py`. The full suite runs with:
|
||||
|
||||
```bash
|
||||
cd pkg/hanzo-tools-browser
|
||||
uv venv .venv --python 3.12
|
||||
.venv/bin/python -m pip install -e .
|
||||
.venv/bin/python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
## API Tool
|
||||
|
||||
Generic REST API tool with OpenAPI specs.
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Browser automation tools for Hanzo AI.
|
||||
|
||||
High-performance async browser automation using Playwright.
|
||||
Supports shared browser instance for low latency and cross-MCP sharing via CDP.
|
||||
Two-process architecture (since 0.5.0):
|
||||
|
||||
Integration with Hanzo Browser Extension:
|
||||
The CDP bridge server starts automatically when browser tools are registered.
|
||||
The browser extension connects to ws://localhost:9223/cdp automatically.
|
||||
No manual setup required - just install the extension and use hanzo-mcp.
|
||||
[Browser ext] --ZAP/WS--> [hanzo-mcp (this Python pkg)] --stdio--> [Agent]
|
||||
|
||||
The hanzo-mcp process hosts a ZAP server on the lowest free port in
|
||||
[9999, 9998, 9997, 9996, 9995]. Browser extensions discover and connect to
|
||||
it directly. No node bridge is in the critical path.
|
||||
|
||||
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``.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -29,10 +33,18 @@ from hanzo_tools.browser.browser_tool import (
|
||||
create_browser_tool,
|
||||
launch_browser_server,
|
||||
)
|
||||
from hanzo_tools.browser.zap_server import (
|
||||
DEFAULT_ZAP_PORTS,
|
||||
ZapClient,
|
||||
ZapServer,
|
||||
get_or_start_server,
|
||||
get_server,
|
||||
shutdown_server,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# CDP Bridge for browser extension integration
|
||||
# CDP Bridge for browser extension integration (legacy, opt-in)
|
||||
try:
|
||||
from hanzo_tools.browser.cdp_bridge_server import (
|
||||
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
|
||||
@@ -44,11 +56,16 @@ except ImportError:
|
||||
CDPBridgeServer = None
|
||||
CDPBridgeClient = None
|
||||
|
||||
# Global CDP bridge server instance (singleton)
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# Tools list for entry point discovery
|
||||
TOOLS = [BrowserTool]
|
||||
|
||||
@@ -60,7 +77,14 @@ __all__ = [
|
||||
# Browser pool
|
||||
"BrowserPool",
|
||||
"launch_browser_server",
|
||||
# CDP Bridge (for browser extension integration)
|
||||
# ZAP (canonical, since 0.5.0)
|
||||
"ZapServer",
|
||||
"ZapClient",
|
||||
"DEFAULT_ZAP_PORTS",
|
||||
"get_or_start_server",
|
||||
"get_server",
|
||||
"shutdown_server",
|
||||
# CDP Bridge (legacy fallback)
|
||||
"CDPBridgeServer",
|
||||
"CDPBridgeClient",
|
||||
"CDP_BRIDGE_AVAILABLE",
|
||||
@@ -75,6 +99,86 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def _run_zap_server(host: str, ports: list[int]) -> 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,
|
||||
ports=ports,
|
||||
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")
|
||||
ports_env = os.environ.get("HANZO_ZAP_PORTS")
|
||||
if ports_env:
|
||||
ports = [int(p) for p in ports_env.split(",") if p.strip().isdigit()]
|
||||
else:
|
||||
ports = list(DEFAULT_ZAP_PORTS)
|
||||
|
||||
_zap_started_event = threading.Event()
|
||||
_zap_thread = threading.Thread(
|
||||
target=_run_zap_server,
|
||||
args=(host, ports),
|
||||
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
|
||||
@@ -170,13 +274,14 @@ def stop_cdp_bridge() -> None:
|
||||
def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
"""Register browser tools with the MCP server.
|
||||
|
||||
This also auto-starts the CDP bridge server for browser extension
|
||||
integration. The extension can connect to ws://localhost:9223/cdp
|
||||
and control browser tabs.
|
||||
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, cdp_bridge)
|
||||
**kwargs: Additional arguments (headless, cdp_endpoint, backend, cdp_bridge)
|
||||
|
||||
Returns:
|
||||
List of registered tools
|
||||
@@ -185,10 +290,16 @@ def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
|
||||
cdp_endpoint = kwargs.get("cdp_endpoint")
|
||||
backend = kwargs.get("backend")
|
||||
|
||||
# Auto-start CDP bridge for browser extension integration
|
||||
# (not needed when backend is explicitly "playwright")
|
||||
auto_cdp_bridge = kwargs.get("cdp_bridge", True)
|
||||
if auto_cdp_bridge and CDP_BRIDGE_AVAILABLE and backend != "playwright":
|
||||
# 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)
|
||||
|
||||
@@ -102,12 +102,36 @@ def get_backend() -> str:
|
||||
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 Hanzo browser extension is connected.
|
||||
|
||||
Args:
|
||||
browser: Optional browser filter ("firefox", "chrome").
|
||||
Tries the local ZAP server first (in-process, microseconds), falls back
|
||||
to the legacy HTTP bridge on :9224.
|
||||
"""
|
||||
# 1) ZAP — if our own MCP holds an extension client locally
|
||||
try:
|
||||
from hanzo_tools.browser.zap_server import get_server
|
||||
|
||||
srv = get_server()
|
||||
if srv is not None and srv.has_client(browser=browser):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Legacy HTTP bridge
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
@@ -120,7 +144,6 @@ async def _check_extension(browser: Optional[str] = None) -> bool:
|
||||
if not data.get("connected", False):
|
||||
return False
|
||||
if browser:
|
||||
# Check if requested browser is among connected clients
|
||||
clients = data.get("client_list", [])
|
||||
return any(
|
||||
browser.lower() in c.get("browser", "").lower()
|
||||
@@ -132,6 +155,79 @@ async def _check_extension(browser: Optional[str] = None) -> bool:
|
||||
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.
|
||||
"""
|
||||
return {
|
||||
"navigate": "Page.navigate",
|
||||
"screenshot": "hanzo.screenshot",
|
||||
"click": "hanzo.click",
|
||||
"fill": "hanzo.fill",
|
||||
"evaluate": "Runtime.evaluate",
|
||||
"tabs": "Target.getTargets",
|
||||
"status": "Browser.getVersion",
|
||||
"url": "hanzo.url",
|
||||
"title": "hanzo.title",
|
||||
"reload": "Page.reload",
|
||||
"go_back": "Page.goBack",
|
||||
"go_forward": "Page.goForward",
|
||||
"tab_info": "hanzo.tabInfo",
|
||||
}.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,
|
||||
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
|
||||
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",
|
||||
}:
|
||||
params[k] = v
|
||||
return params
|
||||
|
||||
|
||||
async def _extension_command(
|
||||
action: str,
|
||||
browser: Optional[str] = None,
|
||||
@@ -141,40 +237,73 @@ async def _extension_command(
|
||||
) -> Optional[dict]:
|
||||
"""Send command to Hanzo browser extension.
|
||||
|
||||
Args:
|
||||
action: The action to perform.
|
||||
browser: Optional browser preference ("firefox", "chrome"). Picks which
|
||||
connected provider to dispatch the command to. With multiple
|
||||
browsers logged in via the extension this is how you target a
|
||||
specific one. ``None`` lets the bridge pick the active provider.
|
||||
tab_id: Optional tab identifier. Accepts the ``targetId`` returned by
|
||||
the ``tabs`` action (e.g. ``"tab-1888868904"``) or a raw numeric
|
||||
tab id. Forwarded as ``tabId`` to the bridge so subsequent
|
||||
navigate/click/screenshot/etc operate on that tab specifically
|
||||
rather than the OS-active tab. Critical when the user has many
|
||||
browser windows open and the wrong one is in focus.
|
||||
client_id: Optional bridge client_id (returned in ``status``). Lets you
|
||||
address one specific connected extension instance when the same
|
||||
user has both Chrome and Firefox bridged to the same bridge.
|
||||
**kwargs: Additional parameters forwarded as JSON body fields.
|
||||
Path order:
|
||||
1. Local in-process ZAP server (microsecond round-trip; preferred).
|
||||
2. Legacy HTTP bridge on :9224 (kept as fallback for non-ZAP MCP clients).
|
||||
|
||||
The transport can be pinned with ``BROWSER_TRANSPORT=zap|http|auto`` —
|
||||
default is ``auto`` which means "ZAP if a connected extension matches,
|
||||
else HTTP".
|
||||
"""
|
||||
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):
|
||||
method = _zap_method_for(action)
|
||||
params = _zap_params(action, tab_id=tab_id, **kwargs)
|
||||
try:
|
||||
raw = await srv.send(
|
||||
method,
|
||||
params,
|
||||
browser=browser,
|
||||
client_id=client_id,
|
||||
)
|
||||
except Exception as e:
|
||||
if transport == "zap":
|
||||
return {"error": str(e), "transport": "zap"}
|
||||
logger.debug("zap dispatch failed, falling back to http: %s", e)
|
||||
else:
|
||||
# CDP Runtime.evaluate returns {result: {type, value}};
|
||||
# unwrap so the caller sees the value directly.
|
||||
result = raw
|
||||
if method == "Runtime.evaluate" and isinstance(raw, dict):
|
||||
cdp = raw.get("result", raw)
|
||||
if isinstance(cdp, dict) and "value" in cdp:
|
||||
result = cdp["value"]
|
||||
elif isinstance(cdp, dict) and cdp.get("type") == "undefined":
|
||||
result = None
|
||||
return {
|
||||
"success": True,
|
||||
"transport": "zap",
|
||||
"result": result,
|
||||
}
|
||||
except ImportError:
|
||||
# zap_server module unavailable — only HTTP path remains.
|
||||
pass
|
||||
|
||||
if transport == "zap":
|
||||
return {
|
||||
"error": "ZAP transport selected but no extension client matched",
|
||||
"transport": "zap",
|
||||
}
|
||||
|
||||
# ---- 2) HTTP fallback ---------------------------------------------
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
# Filter out None values
|
||||
payload = {"action": action}
|
||||
payload: dict[str, Any] = {"action": action}
|
||||
if browser:
|
||||
payload["browser"] = browser
|
||||
if tab_id is not None:
|
||||
# Bridge accepts numeric tabId or string targetId. Strip the
|
||||
# "tab-" prefix so the extension's chrome.tabs.get() finds it.
|
||||
t = tab_id
|
||||
if isinstance(t, str) and t.startswith("tab-"):
|
||||
t = t[4:]
|
||||
try:
|
||||
payload["tabId"] = int(t)
|
||||
except (TypeError, ValueError):
|
||||
payload["tabId"] = t
|
||||
norm_tab = _normalize_tab_id(tab_id)
|
||||
if norm_tab is not None:
|
||||
payload["tabId"] = norm_tab
|
||||
if client_id:
|
||||
payload["clientId"] = client_id
|
||||
payload.update({k: v for k, v in kwargs.items() if v is not None})
|
||||
@@ -186,11 +315,13 @@ async def _extension_command(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
body = await resp.json()
|
||||
body.setdefault("transport", "http")
|
||||
return body
|
||||
else:
|
||||
body = await resp.text()
|
||||
logger.debug(f"Extension command {action} returned {resp.status}: {body}")
|
||||
return {"error": body, "status": resp.status}
|
||||
text = await resp.text()
|
||||
logger.debug(f"Extension command {action} returned {resp.status}: {text}")
|
||||
return {"error": text, "status": resp.status, "transport": "http"}
|
||||
except Exception as e:
|
||||
logger.debug(f"Extension command failed: {e}")
|
||||
return None
|
||||
@@ -396,6 +527,9 @@ Action = Annotated[
|
||||
"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
|
||||
@@ -706,10 +840,32 @@ class BrowserTool(BaseTool):
|
||||
self.backend = backend or get_backend()
|
||||
self.timeout = 30000
|
||||
|
||||
# Auto-start CDP bridge for browser extension integration.
|
||||
# This enables the extension (Chrome/Firefox/Safari) to connect
|
||||
# and be preferred over Playwright when backend != "playwright".
|
||||
if self.backend != "playwright":
|
||||
# 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"):
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# 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:
|
||||
@@ -865,6 +1021,65 @@ CATEGORIES:
|
||||
timeout = timeout or self.timeout
|
||||
sel = selector or ref
|
||||
|
||||
# === LOCAL ACTIONS (handled in-process, no extension/Playwright) ===
|
||||
if action == "list_mcp_instances":
|
||||
try:
|
||||
from hanzo_tools.browser.zap_server import ZapServer
|
||||
|
||||
instances = ZapServer.list_mcp_instances()
|
||||
return {
|
||||
"success": True,
|
||||
"mcp_instances": instances,
|
||||
"count": len(instances),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": f"failed to list mcp instances: {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"}
|
||||
ttl = float(timeout) / 1000 if timeout else DEFAULT_LEASE_TTL
|
||||
lease = srv.claim(client.client_id, ttl=ttl)
|
||||
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"}
|
||||
# Without an explicit client_id, drop every lease this MCP holds.
|
||||
if client_id:
|
||||
released = srv.release(client_id)
|
||||
return {"success": released, "client_id": client_id}
|
||||
released_all = []
|
||||
for c in list(srv.clients):
|
||||
if srv.release(c.client_id):
|
||||
released_all.append(c.client_id)
|
||||
return {"success": True, "released": released_all}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# === BACKEND-AWARE ROUTING ===
|
||||
# Actions supported by the CDP bridge / browser extension
|
||||
extension_actions = {
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
"""ZAP (Zero-latency Agent Protocol) server for hanzo-tools-browser.
|
||||
|
||||
Wire format (compatible with @hanzo/browser-extension/src/shared/zap.ts):
|
||||
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload]
|
||||
|
||||
Message types:
|
||||
MSG_HANDSHAKE = 0x01 client -> server
|
||||
MSG_HANDSHAKE_OK = 0x02 server -> client
|
||||
MSG_REQUEST = 0x10 bidirectional
|
||||
MSG_RESPONSE = 0x11 bidirectional
|
||||
MSG_PING = 0xFE
|
||||
MSG_PONG = 0xFF
|
||||
|
||||
Each hanzo-mcp process runs ONE ZapServer that:
|
||||
1. Claims the lowest free port from [9999, 9998, 9997, 9996, 9995] using a
|
||||
POSIX flock at ~/.hanzo/extension/zap-{port}.lock so multiple MCPs don't
|
||||
collide.
|
||||
2. Accepts WebSocket connections from browser extensions, registers them by
|
||||
{client_id, browser, version, capabilities, ws}.
|
||||
3. Exposes ``send(method, params, browser=None, client_id=None)`` for the
|
||||
browser tool to dispatch RPC calls into a connected extension.
|
||||
4. Persists its presence to ~/.hanzo/extension/config.json:mcp_instances so
|
||||
sibling MCPs can introspect the cluster.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Wire format constants (canonical: shared/zap.ts)
|
||||
ZAP_MAGIC = b"\x5a\x41\x50\x01"
|
||||
HEADER_SIZE = 9 # 4 magic + 1 type + 4 length
|
||||
|
||||
MSG_HANDSHAKE = 0x01
|
||||
MSG_HANDSHAKE_OK = 0x02
|
||||
MSG_REQUEST = 0x10
|
||||
MSG_RESPONSE = 0x11
|
||||
MSG_PING = 0xFE
|
||||
MSG_PONG = 0xFF
|
||||
|
||||
# Port preference (lowest first; matches DEFAULT_ZAP_PORTS in shared/zap.ts)
|
||||
DEFAULT_ZAP_PORTS: list[int] = [9999, 9998, 9997, 9996, 9995]
|
||||
|
||||
# Browser-resolution preference (mirror cdp-bridge-server.ts)
|
||||
DEFAULT_BROWSER_PREFERENCE: list[str] = ["firefox", "safari", "edge", "chrome"]
|
||||
|
||||
# How long an exclusive browser lease lasts unless explicitly extended.
|
||||
DEFAULT_LEASE_TTL = 60.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wire format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def encode(msg_type: int, payload: Any) -> bytes:
|
||||
"""Encode a ZAP frame.
|
||||
|
||||
JSON-encoded payload, length-prefixed, big-endian.
|
||||
"""
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
return ZAP_MAGIC + bytes([msg_type]) + struct.pack(">I", len(body)) + body
|
||||
|
||||
|
||||
def decode(data: bytes) -> Optional[tuple[int, Any]]:
|
||||
"""Decode a single ZAP frame. Returns ``None`` for malformed input.
|
||||
|
||||
Caller must hand a complete frame (websockets.recv() guarantees this for
|
||||
binary messages — frame == one ws message).
|
||||
"""
|
||||
if len(data) < HEADER_SIZE:
|
||||
return None
|
||||
if data[0:4] != ZAP_MAGIC:
|
||||
return None
|
||||
msg_type = data[4]
|
||||
(length,) = struct.unpack(">I", data[5:9])
|
||||
if len(data) < HEADER_SIZE + length:
|
||||
return None
|
||||
body = data[HEADER_SIZE : HEADER_SIZE + length]
|
||||
payload = json.loads(body.decode("utf-8")) if length else None
|
||||
return msg_type, payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lock file (cross-MCP port arbitration)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _lock_dir() -> Path:
|
||||
d = Path.home() / ".hanzo" / "extension"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _claim_port_lock(port: int) -> Optional[int]:
|
||||
"""Try to acquire an exclusive flock on ``zap-{port}.lock``.
|
||||
|
||||
Returns the open file descriptor on success, ``None`` if another
|
||||
process already holds it. The fd must be kept open for the lifetime
|
||||
of the server (closing releases the lock).
|
||||
"""
|
||||
lock_path = _lock_dir() / f"zap-{port}.lock"
|
||||
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
os.close(fd)
|
||||
if e.errno in (errno.EWOULDBLOCK, errno.EACCES):
|
||||
return None
|
||||
raise
|
||||
# Write our pid so the file is informative
|
||||
os.ftruncate(fd, 0)
|
||||
os.write(fd, f"{os.getpid()}\n".encode("utf-8"))
|
||||
return fd
|
||||
|
||||
|
||||
def _release_port_lock(fd: Optional[int]) -> None:
|
||||
if fd is not None:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _port_is_free(port: int, host: str = "127.0.0.1") -> bool:
|
||||
"""Best-effort TCP probe — does a bind succeed?"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind((host, port))
|
||||
s.close()
|
||||
return True
|
||||
except OSError:
|
||||
s.close()
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared config (~/.hanzo/extension/config.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
return _lock_dir() / "config.json"
|
||||
|
||||
|
||||
def _config_lock_path() -> Path:
|
||||
return _lock_dir() / "config.json.lock"
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
p = _config_path()
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _atomic_update_config(mutate: Callable[[dict], None]) -> dict:
|
||||
"""Read-modify-write ~/.hanzo/extension/config.json under fcntl flock."""
|
||||
lock_path = _config_lock_path()
|
||||
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
cfg = _read_config()
|
||||
mutate(cfg)
|
||||
tmp = _config_path().with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(cfg, indent=2))
|
||||
os.replace(tmp, _config_path())
|
||||
return cfg
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client tracking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZapClient:
|
||||
"""A connected browser extension."""
|
||||
|
||||
client_id: str
|
||||
browser: str
|
||||
version: str
|
||||
capabilities: list[str]
|
||||
ws: Any # websockets.WebSocketServerProtocol — typed Any to avoid hard dep
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
last_active: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"client_id": self.client_id,
|
||||
"browser": self.browser,
|
||||
"version": self.version,
|
||||
"capabilities": self.capabilities,
|
||||
"connected_at": self.connected_at,
|
||||
"last_active": self.last_active,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowserLease:
|
||||
"""An exclusive lease on a browser client (sub-agent claim/release)."""
|
||||
|
||||
client_id: str
|
||||
holder: str
|
||||
expires_at: float
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
return time.time() >= self.expires_at
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ZapServer:
|
||||
"""Single-port ZAP server hosted inside a hanzo-mcp process.
|
||||
|
||||
One instance per Python MCP. Lifetime = MCP lifetime. Concurrent
|
||||
extensions register and dispatch independently.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
ports: Optional[list[int]] = None,
|
||||
agent_label: Optional[str] = None,
|
||||
server_id: Optional[str] = None,
|
||||
request_timeout: float = 30.0,
|
||||
):
|
||||
self.host = host
|
||||
self.ports = list(ports or DEFAULT_ZAP_PORTS)
|
||||
self.agent_label = agent_label or os.environ.get("HANZO_AGENT_LABEL", "")
|
||||
self.server_id = server_id or f"mcp-py-{os.getpid()}"
|
||||
self.request_timeout = request_timeout
|
||||
|
||||
self._port: Optional[int] = None
|
||||
self._lock_fd: Optional[int] = None
|
||||
self._server: Any = None # websockets server
|
||||
self._clients: dict[str, ZapClient] = {}
|
||||
self._ws_to_id: dict[Any, str] = {}
|
||||
self._pending: dict[str, asyncio.Future] = {}
|
||||
self._req_counter = 0
|
||||
self._leases: dict[str, BrowserLease] = {} # client_id -> lease
|
||||
self._tools_manifest: list[dict] = [
|
||||
{
|
||||
"name": "browser",
|
||||
"description": "Hanzo browser tool (Python MCP, ZAP-native)",
|
||||
"inputSchema": {"type": "object"},
|
||||
}
|
||||
]
|
||||
|
||||
# ---- lifecycle ------------------------------------------------------
|
||||
|
||||
async def start(self) -> Optional[int]:
|
||||
"""Bind to the lowest-numbered free port. Returns port or None."""
|
||||
try:
|
||||
import websockets # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning("websockets not installed; ZAP server disabled")
|
||||
return None
|
||||
|
||||
for port in self.ports:
|
||||
fd = _claim_port_lock(port)
|
||||
if fd is None:
|
||||
logger.debug("zap port %d already locked by another mcp", port)
|
||||
continue
|
||||
if not _port_is_free(port, self.host):
|
||||
_release_port_lock(fd)
|
||||
logger.debug("zap port %d in use (no lock but socket bound)", port)
|
||||
continue
|
||||
try:
|
||||
from websockets.asyncio.server import serve as _serve
|
||||
|
||||
async def _handler(websocket):
|
||||
await self._handle_connection(websocket)
|
||||
|
||||
self._server = await _serve(_handler, self.host, port)
|
||||
except ImportError:
|
||||
# Older websockets API (< 13)
|
||||
from websockets.server import serve as _serve # type: ignore
|
||||
|
||||
async def _handler_legacy(websocket, _path):
|
||||
await self._handle_connection(websocket)
|
||||
|
||||
self._server = await _serve(_handler_legacy, self.host, port) # type: ignore
|
||||
except OSError as e:
|
||||
_release_port_lock(fd)
|
||||
logger.debug("zap bind on %d failed: %s", port, e)
|
||||
continue
|
||||
|
||||
self._port = port
|
||||
self._lock_fd = fd
|
||||
self._register_in_config()
|
||||
logger.info(
|
||||
"ZAP server listening on ws://%s:%d (mcp=%s, agent=%s)",
|
||||
self.host,
|
||||
port,
|
||||
self.server_id,
|
||||
self.agent_label or "?",
|
||||
)
|
||||
return port
|
||||
|
||||
logger.warning("ZAP: no free port in %s", self.ports)
|
||||
return None
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Gracefully shut down: close clients, release lock, drop registry entry."""
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
try:
|
||||
await self._server.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._server = None
|
||||
|
||||
for client in list(self._clients.values()):
|
||||
try:
|
||||
await client.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._clients.clear()
|
||||
self._ws_to_id.clear()
|
||||
|
||||
self._unregister_from_config()
|
||||
_release_port_lock(self._lock_fd)
|
||||
self._lock_fd = None
|
||||
self._port = None
|
||||
|
||||
# ---- public API -----------------------------------------------------
|
||||
|
||||
@property
|
||||
def port(self) -> Optional[int]:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def clients(self) -> list[ZapClient]:
|
||||
return list(self._clients.values())
|
||||
|
||||
def has_client(self, browser: Optional[str] = None) -> bool:
|
||||
if not self._clients:
|
||||
return False
|
||||
if not browser:
|
||||
return True
|
||||
b = browser.lower()
|
||||
return any(b in c.browser.lower() for c in self._clients.values())
|
||||
|
||||
def resolve_client(
|
||||
self,
|
||||
client_id: Optional[str] = None,
|
||||
browser: Optional[str] = None,
|
||||
) -> Optional[ZapClient]:
|
||||
"""Pick which connected extension to dispatch to.
|
||||
|
||||
Priority: explicit client_id > browser preference > most-recent-active
|
||||
> default browser preference list.
|
||||
"""
|
||||
if client_id and client_id in self._clients:
|
||||
return self._clients[client_id]
|
||||
|
||||
candidates = list(self._clients.values())
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
if browser:
|
||||
b = browser.lower()
|
||||
matches = [c for c in candidates if b in c.browser.lower()]
|
||||
if not matches:
|
||||
return None
|
||||
return max(matches, key=lambda c: c.last_active)
|
||||
|
||||
# No explicit selector: respect global default preference list.
|
||||
for pref in DEFAULT_BROWSER_PREFERENCE:
|
||||
matches = [c for c in candidates if pref in c.browser.lower()]
|
||||
if matches:
|
||||
return max(matches, key=lambda c: c.last_active)
|
||||
|
||||
return max(candidates, key=lambda c: c.last_active)
|
||||
|
||||
async def send(
|
||||
self,
|
||||
method: str,
|
||||
params: Optional[dict] = None,
|
||||
*,
|
||||
browser: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Any:
|
||||
"""Send a method request to a connected extension and await result.
|
||||
|
||||
Raises ``RuntimeError`` if no client matches.
|
||||
"""
|
||||
client = self.resolve_client(client_id=client_id, browser=browser)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No ZAP-connected browser extension"
|
||||
+ (f" matching '{browser}'" if browser else "")
|
||||
)
|
||||
|
||||
# Honour leases: if a different holder has a non-expired lease on this
|
||||
# client, reject.
|
||||
lease = self._leases.get(client.client_id)
|
||||
if lease and not lease.expired and lease.holder != self.server_id:
|
||||
raise RuntimeError(
|
||||
f"browser leased by {lease.holder} until {time.ctime(lease.expires_at)}"
|
||||
)
|
||||
|
||||
req_id = self._next_req_id()
|
||||
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
self._pending[req_id] = future
|
||||
client.last_active = time.time()
|
||||
|
||||
try:
|
||||
await client.ws.send(
|
||||
encode(MSG_REQUEST, {"id": req_id, "method": method, "params": params or {}})
|
||||
)
|
||||
return await asyncio.wait_for(future, timeout=timeout or self.request_timeout)
|
||||
finally:
|
||||
self._pending.pop(req_id, None)
|
||||
|
||||
# ---- leases ---------------------------------------------------------
|
||||
|
||||
def claim(self, client_id: str, ttl: float = DEFAULT_LEASE_TTL) -> BrowserLease:
|
||||
"""Take an exclusive lease on a browser client for ``ttl`` seconds.
|
||||
|
||||
Raises ``RuntimeError`` if already held by someone else.
|
||||
"""
|
||||
lease = self._leases.get(client_id)
|
||||
if lease and not lease.expired and lease.holder != self.server_id:
|
||||
raise RuntimeError(
|
||||
f"already leased by {lease.holder} until {time.ctime(lease.expires_at)}"
|
||||
)
|
||||
new = BrowserLease(
|
||||
client_id=client_id,
|
||||
holder=self.server_id,
|
||||
expires_at=time.time() + ttl,
|
||||
)
|
||||
self._leases[client_id] = new
|
||||
return new
|
||||
|
||||
def release(self, client_id: str) -> bool:
|
||||
"""Release a lease this server holds. Returns True if released."""
|
||||
lease = self._leases.get(client_id)
|
||||
if lease and lease.holder == self.server_id:
|
||||
del self._leases[client_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---- registry (cross-MCP visibility) -------------------------------
|
||||
|
||||
def _register_in_config(self) -> None:
|
||||
def mut(cfg: dict) -> None:
|
||||
instances = cfg.setdefault("mcp_instances", [])
|
||||
# purge stale (dead pids) and any prior entry for our own
|
||||
# (pid, port). In production each MCP is its own process so
|
||||
# de-dup by pid alone would suffice; we key on (pid, port) so
|
||||
# tests and edge cases that bring up two ZAP servers under one
|
||||
# pid both stay visible.
|
||||
instances[:] = [
|
||||
i
|
||||
for i in instances
|
||||
if _pid_alive(i.get("pid"))
|
||||
and not (i.get("pid") == os.getpid() and i.get("port") == self._port)
|
||||
]
|
||||
instances.append(
|
||||
{
|
||||
"port": self._port,
|
||||
"pid": os.getpid(),
|
||||
"started_at": time.time(),
|
||||
"agent_label": self.agent_label,
|
||||
"server_id": self.server_id,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
_atomic_update_config(mut)
|
||||
except Exception as e:
|
||||
logger.warning("config registry write failed: %s", e)
|
||||
|
||||
def _unregister_from_config(self) -> None:
|
||||
target_pid = os.getpid()
|
||||
target_port = self._port
|
||||
|
||||
def mut(cfg: dict) -> None:
|
||||
instances = cfg.get("mcp_instances", [])
|
||||
cfg["mcp_instances"] = [
|
||||
i
|
||||
for i in instances
|
||||
if not (i.get("pid") == target_pid and i.get("port") == target_port)
|
||||
]
|
||||
|
||||
try:
|
||||
_atomic_update_config(mut)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def list_mcp_instances() -> list[dict]:
|
||||
"""Read the cluster registry (filters dead pids)."""
|
||||
cfg = _read_config()
|
||||
instances = cfg.get("mcp_instances", [])
|
||||
return [i for i in instances if _pid_alive(i.get("pid"))]
|
||||
|
||||
# ---- ws handler -----------------------------------------------------
|
||||
|
||||
async def _handle_connection(self, websocket: Any) -> None:
|
||||
try:
|
||||
async for raw in websocket:
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
# Spec is binary frames; ignore stray text.
|
||||
continue
|
||||
decoded = decode(bytes(raw))
|
||||
if decoded is None:
|
||||
logger.debug("zap: malformed frame from %s", websocket)
|
||||
continue
|
||||
msg_type, payload = decoded
|
||||
await self._dispatch(websocket, msg_type, payload or {})
|
||||
except Exception as e:
|
||||
# websockets normalises connection-closed via exception flow;
|
||||
# don't spam logs.
|
||||
logger.debug("zap connection ended: %s", e)
|
||||
finally:
|
||||
cid = self._ws_to_id.pop(websocket, None)
|
||||
if cid:
|
||||
self._clients.pop(cid, None)
|
||||
self._leases.pop(cid, None)
|
||||
logger.info("zap: client disconnected %s", cid)
|
||||
|
||||
async def _dispatch(self, websocket: Any, msg_type: int, payload: dict) -> None:
|
||||
if msg_type == MSG_HANDSHAKE:
|
||||
client_id = payload.get("clientId") or f"ext-{int(time.time() * 1000)}"
|
||||
client = ZapClient(
|
||||
client_id=client_id,
|
||||
browser=payload.get("browser", "unknown"),
|
||||
version=payload.get("version", "0"),
|
||||
capabilities=list(payload.get("capabilities") or []),
|
||||
ws=websocket,
|
||||
)
|
||||
self._clients[client_id] = client
|
||||
self._ws_to_id[websocket] = client_id
|
||||
logger.info(
|
||||
"zap: client connected %s (%s v%s, %d caps)",
|
||||
client_id,
|
||||
client.browser,
|
||||
client.version,
|
||||
len(client.capabilities),
|
||||
)
|
||||
await websocket.send(
|
||||
encode(
|
||||
MSG_HANDSHAKE_OK,
|
||||
{
|
||||
"serverId": self.server_id,
|
||||
"name": "hanzo-mcp",
|
||||
"agentLabel": self.agent_label,
|
||||
"tools": self._tools_manifest,
|
||||
},
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if msg_type == MSG_PING:
|
||||
await websocket.send(encode(MSG_PONG, {}))
|
||||
return
|
||||
|
||||
if msg_type == MSG_PONG:
|
||||
return
|
||||
|
||||
# MSG_RESPONSE: extension is answering an RPC we sent.
|
||||
if msg_type == MSG_RESPONSE:
|
||||
req_id = payload.get("id")
|
||||
future = self._pending.get(req_id) if req_id else None
|
||||
if future is None or future.done():
|
||||
return
|
||||
if "error" in payload and payload["error"]:
|
||||
err = payload["error"]
|
||||
msg = err.get("message") if isinstance(err, dict) else str(err)
|
||||
future.set_exception(RuntimeError(msg or "ZAP error"))
|
||||
else:
|
||||
future.set_result(payload.get("result"))
|
||||
return
|
||||
|
||||
# MSG_REQUEST: extension is calling US (notifications/elementSelected,
|
||||
# etc.). Acknowledge so the protocol doesn't block.
|
||||
if msg_type == MSG_REQUEST:
|
||||
req_id = payload.get("id")
|
||||
method = payload.get("method", "")
|
||||
# Update last_active
|
||||
cid = self._ws_to_id.get(websocket)
|
||||
if cid and cid in self._clients:
|
||||
self._clients[cid].last_active = time.time()
|
||||
# Notifications are fire-and-forget; everything else gets a stub
|
||||
# acknowledgement (caller can still install custom handlers via
|
||||
# ``on_request`` if needed).
|
||||
if req_id is not None:
|
||||
await websocket.send(encode(MSG_RESPONSE, {"id": req_id, "result": {"ack": True, "method": method}}))
|
||||
return
|
||||
|
||||
logger.debug("zap: unknown msg type 0x%02x", msg_type)
|
||||
|
||||
def _next_req_id(self) -> str:
|
||||
self._req_counter += 1
|
||||
return f"py-{self._req_counter}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pid_alive(pid: Optional[int]) -> bool:
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError as e:
|
||||
# ESRCH = no such process; EPERM = exists but not ours
|
||||
return e.errno == errno.EPERM
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-wide singleton (one ZAP server per hanzo-mcp)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_singleton: Optional[ZapServer] = None
|
||||
_singleton_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_or_start_server(
|
||||
*,
|
||||
host: str = "127.0.0.1",
|
||||
ports: Optional[list[int]] = None,
|
||||
agent_label: Optional[str] = None,
|
||||
) -> Optional[ZapServer]:
|
||||
"""Return the process-wide ZAP server, starting it if needed.
|
||||
|
||||
Returns ``None`` if no port could be bound (other MCPs hold all 5).
|
||||
"""
|
||||
global _singleton
|
||||
async with _singleton_lock:
|
||||
if _singleton is not None and _singleton.port is not None:
|
||||
return _singleton
|
||||
srv = ZapServer(host=host, ports=ports, agent_label=agent_label)
|
||||
port = await srv.start()
|
||||
if port is None:
|
||||
return None
|
||||
_singleton = srv
|
||||
return srv
|
||||
|
||||
|
||||
def get_server() -> Optional[ZapServer]:
|
||||
"""Return the current singleton (or None if not started)."""
|
||||
return _singleton
|
||||
|
||||
|
||||
async def shutdown_server() -> None:
|
||||
global _singleton
|
||||
async with _singleton_lock:
|
||||
if _singleton is not None:
|
||||
await _singleton.stop()
|
||||
_singleton = None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ZapClient",
|
||||
"ZapServer",
|
||||
"BrowserLease",
|
||||
"DEFAULT_ZAP_PORTS",
|
||||
"DEFAULT_BROWSER_PREFERENCE",
|
||||
"DEFAULT_LEASE_TTL",
|
||||
"MSG_HANDSHAKE",
|
||||
"MSG_HANDSHAKE_OK",
|
||||
"MSG_REQUEST",
|
||||
"MSG_RESPONSE",
|
||||
"MSG_PING",
|
||||
"MSG_PONG",
|
||||
"ZAP_MAGIC",
|
||||
"encode",
|
||||
"decode",
|
||||
"get_or_start_server",
|
||||
"get_server",
|
||||
"shutdown_server",
|
||||
]
|
||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-browser"
|
||||
version = "0.4.6"
|
||||
description = "Browser automation tools with extension-first routing - uses Hanzo browser extension when available, Playwright as fallback"
|
||||
version = "0.5.0"
|
||||
description = "Browser automation tools with ZAP-native extension routing — Python MCP hosts the ZAP server directly, no node bridge in the critical path. Playwright remains as offline fallback."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Latency benchmarks for ZAP vs HTTP-bridge transports.
|
||||
|
||||
This isn't strict pass/fail — it asserts a generous upper bound on ZAP
|
||||
median round-trip and prints both numbers for comparison so PR reviewers
|
||||
can see the win.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from hanzo_tools.browser import zap_server as zs
|
||||
from tests.test_zap_server import MockExtensionClient, _free_ports # type: ignore
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _percentile(values: list[float], p: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
s = sorted(values)
|
||||
idx = max(0, min(len(s) - 1, int(round(p / 100.0 * (len(s) - 1)))))
|
||||
return s[idx]
|
||||
|
||||
|
||||
async def _measure(label: str, n: int, op: Callable[[], asyncio.Future]) -> dict:
|
||||
# warm-up
|
||||
for _ in range(5):
|
||||
await op()
|
||||
samples: list[float] = []
|
||||
for _ in range(n):
|
||||
t0 = time.perf_counter()
|
||||
await op()
|
||||
samples.append((time.perf_counter() - t0) * 1000.0)
|
||||
return {
|
||||
"label": label,
|
||||
"n": n,
|
||||
"min_ms": min(samples),
|
||||
"p50_ms": statistics.median(samples),
|
||||
"p95_ms": _percentile(samples, 95),
|
||||
"max_ms": max(samples),
|
||||
}
|
||||
|
||||
|
||||
async def test_zap_round_trip_under_5ms_median(tmp_path, monkeypatch):
|
||||
"""ZAP `evaluate('1+1')` median round-trip must be sub-5ms locally."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
ports = _free_ports(5)
|
||||
|
||||
srv = zs.ZapServer(ports=ports)
|
||||
port = await srv.start()
|
||||
assert port is not None
|
||||
client = MockExtensionClient(port, browser="firefox", client_id="bench")
|
||||
await client.connect()
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# Extension responds immediately to evaluate("1+1") with the value.
|
||||
client.response_handler = lambda m, p: {"result": {"type": "number", "value": 2}}
|
||||
|
||||
try:
|
||||
result = await _measure(
|
||||
"zap",
|
||||
n=200,
|
||||
op=lambda: srv.send("Runtime.evaluate", {"expression": "1+1"}),
|
||||
)
|
||||
# Print so CI logs surface the number.
|
||||
print(f"\n[ZAP bench] {json.dumps(result, indent=2)}")
|
||||
# Generous bound: 5ms p50 on local loopback is safe even on a
|
||||
# busy laptop; production target is <1ms.
|
||||
assert result["p50_ms"] < 5.0, f"ZAP p50 too high: {result}"
|
||||
finally:
|
||||
await client.close()
|
||||
await srv.stop()
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Tests for hanzo_tools.browser.zap_server.
|
||||
|
||||
Covers:
|
||||
- Wire format encode/decode round-trip and parity with shared/zap.ts.
|
||||
- Server bind, port-arbitration via flock (multi-MCP coexistence).
|
||||
- Mock extension client: register, send response to RPC, ping/pong.
|
||||
- Browser resolution priority (client_id > browser > default preference).
|
||||
- Browser leases (claim / release / reject when held by other holder).
|
||||
- Cluster registry in ~/.hanzo/extension/config.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import websockets
|
||||
from websockets.asyncio.client import connect as ws_connect
|
||||
|
||||
from hanzo_tools.browser import zap_server as zs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wire format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWireFormat:
|
||||
def test_magic(self):
|
||||
assert zs.ZAP_MAGIC == b"\x5a\x41\x50\x01"
|
||||
|
||||
def test_constants_match_extension(self):
|
||||
# Must stay locked to shared/zap.ts canonical values.
|
||||
assert zs.MSG_HANDSHAKE == 0x01
|
||||
assert zs.MSG_HANDSHAKE_OK == 0x02
|
||||
assert zs.MSG_REQUEST == 0x10
|
||||
assert zs.MSG_RESPONSE == 0x11
|
||||
assert zs.MSG_PING == 0xFE
|
||||
assert zs.MSG_PONG == 0xFF
|
||||
|
||||
def test_default_ports(self):
|
||||
assert zs.DEFAULT_ZAP_PORTS == [9999, 9998, 9997, 9996, 9995]
|
||||
|
||||
def test_encode_layout(self):
|
||||
frame = zs.encode(zs.MSG_REQUEST, {"id": "x", "method": "y"})
|
||||
assert frame[:4] == zs.ZAP_MAGIC
|
||||
assert frame[4] == zs.MSG_REQUEST
|
||||
(length,) = struct.unpack(">I", frame[5:9])
|
||||
assert length == len(frame) - zs.HEADER_SIZE
|
||||
assert json.loads(frame[9:].decode()) == {"id": "x", "method": "y"}
|
||||
|
||||
def test_round_trip(self):
|
||||
for msg_type in (
|
||||
zs.MSG_HANDSHAKE,
|
||||
zs.MSG_HANDSHAKE_OK,
|
||||
zs.MSG_REQUEST,
|
||||
zs.MSG_RESPONSE,
|
||||
zs.MSG_PING,
|
||||
zs.MSG_PONG,
|
||||
):
|
||||
payload = {"a": 1, "b": [1, 2, 3], "c": {"nested": True}}
|
||||
decoded = zs.decode(zs.encode(msg_type, payload))
|
||||
assert decoded is not None
|
||||
assert decoded[0] == msg_type
|
||||
assert decoded[1] == payload
|
||||
|
||||
def test_decode_rejects_bad_magic(self):
|
||||
bad = b"\xff\xff\xff\xff" + b"\x10" + struct.pack(">I", 0)
|
||||
assert zs.decode(bad) is None
|
||||
|
||||
def test_decode_rejects_short_frame(self):
|
||||
assert zs.decode(b"") is None
|
||||
assert zs.decode(b"\x5a\x41\x50") is None
|
||||
|
||||
def test_decode_handles_empty_payload(self):
|
||||
frame = zs.encode(zs.MSG_PING, {})
|
||||
decoded = zs.decode(frame)
|
||||
assert decoded is not None and decoded[1] == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mock extension client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MockExtensionClient:
|
||||
"""Minimal browser-extension client speaking ZAP wire format.
|
||||
|
||||
Registers on connect, exposes ``response_handler`` so tests can react
|
||||
to inbound MSG_REQUEST and reply with MSG_RESPONSE.
|
||||
"""
|
||||
|
||||
def __init__(self, port: int, *, browser: str = "firefox", client_id: str | None = None):
|
||||
self.port = port
|
||||
self.browser = browser
|
||||
self.client_id = client_id or f"ext-test-{int(time.time() * 1000)}"
|
||||
self.ws: websockets.ClientConnection | None = None
|
||||
self.received: list[tuple[int, dict]] = []
|
||||
self.handshake_ok: dict | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
self.response_handler: callable | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
self.ws = await ws_connect(f"ws://127.0.0.1:{self.port}")
|
||||
await self.ws.send(
|
||||
zs.encode(
|
||||
zs.MSG_HANDSHAKE,
|
||||
{
|
||||
"clientId": self.client_id,
|
||||
"clientType": "browser_extension",
|
||||
"browser": self.browser,
|
||||
"version": "test-0.0",
|
||||
"capabilities": ["navigate", "evaluate", "click"],
|
||||
},
|
||||
)
|
||||
)
|
||||
# Wait for HANDSHAKE_OK
|
||||
raw = await asyncio.wait_for(self.ws.recv(), timeout=2.0)
|
||||
decoded = zs.decode(bytes(raw))
|
||||
assert decoded is not None and decoded[0] == zs.MSG_HANDSHAKE_OK
|
||||
self.handshake_ok = decoded[1]
|
||||
self._task = asyncio.create_task(self._run())
|
||||
|
||||
async def _run(self) -> None:
|
||||
assert self.ws is not None
|
||||
try:
|
||||
async for raw in self.ws:
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
continue
|
||||
decoded = zs.decode(bytes(raw))
|
||||
if decoded is None:
|
||||
continue
|
||||
msg_type, payload = decoded
|
||||
self.received.append((msg_type, payload or {}))
|
||||
if msg_type == zs.MSG_REQUEST and self.response_handler is not None:
|
||||
method = payload.get("method") if payload else ""
|
||||
params = payload.get("params") if payload else {}
|
||||
req_id = payload.get("id") if payload else None
|
||||
try:
|
||||
result = self.response_handler(method, params)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
await self.ws.send(zs.encode(zs.MSG_RESPONSE, {"id": req_id, "result": result}))
|
||||
except Exception as e:
|
||||
await self.ws.send(
|
||||
zs.encode(zs.MSG_RESPONSE, {"id": req_id, "error": {"code": -1, "message": str(e)}})
|
||||
)
|
||||
elif msg_type == zs.MSG_PING:
|
||||
await self.ws.send(zs.encode(zs.MSG_PONG, {}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
if self.ws:
|
||||
await self.ws.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Sandbox ~/.hanzo so tests never collide with real user state."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
yield tmp_path
|
||||
|
||||
|
||||
def _free_ports(n: int) -> list[int]:
|
||||
"""Return ``n`` distinct, currently-unbound TCP ports."""
|
||||
import socket as _s
|
||||
|
||||
ports: list[int] = []
|
||||
socks: list[_s.socket] = []
|
||||
try:
|
||||
for _ in range(n):
|
||||
s = _s.socket(_s.AF_INET, _s.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
socks.append(s)
|
||||
ports.append(s.getsockname()[1])
|
||||
finally:
|
||||
for s in socks:
|
||||
s.close()
|
||||
return ports
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def free_ports():
|
||||
"""5 ephemeral, currently-free ports — replaces the well-known
|
||||
9999..9995 range so tests don't depend on machine state."""
|
||||
return _free_ports(5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def server(isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
port = await srv.start()
|
||||
assert port is not None, "ZAP server failed to bind"
|
||||
yield srv
|
||||
await srv.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(server):
|
||||
c = MockExtensionClient(server.port)
|
||||
await c.connect()
|
||||
# Allow the server to register the connection
|
||||
await asyncio.sleep(0.05)
|
||||
yield c
|
||||
await c.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bind / shutdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestServerLifecycle:
|
||||
async def test_binds_on_first_free_port(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
try:
|
||||
port = await srv.start()
|
||||
# Must be the FIRST entry (lowest-priority) when all are free.
|
||||
assert port == free_ports[0]
|
||||
finally:
|
||||
await srv.stop()
|
||||
|
||||
async def test_lock_file_created(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
try:
|
||||
port = await srv.start()
|
||||
assert port is not None
|
||||
lock_path = Path(isolated_home) / ".hanzo" / "extension" / f"zap-{port}.lock"
|
||||
assert lock_path.exists()
|
||||
finally:
|
||||
await srv.stop()
|
||||
|
||||
async def test_two_servers_pick_different_ports(self, isolated_home, free_ports):
|
||||
a = zs.ZapServer(ports=free_ports)
|
||||
b = zs.ZapServer(ports=free_ports)
|
||||
try:
|
||||
port_a = await a.start()
|
||||
port_b = await b.start()
|
||||
assert port_a is not None
|
||||
assert port_b is not None
|
||||
assert port_a != port_b
|
||||
# b should pick a strictly LOWER-priority slot than a
|
||||
assert free_ports.index(port_b) > free_ports.index(port_a)
|
||||
finally:
|
||||
await a.stop()
|
||||
await b.stop()
|
||||
|
||||
async def test_stop_releases_lock(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
port = await srv.start()
|
||||
assert port is not None
|
||||
await srv.stop()
|
||||
# Now another fresh server should be able to claim that same port
|
||||
srv2 = zs.ZapServer(ports=[port])
|
||||
port2 = await srv2.start()
|
||||
assert port2 == port
|
||||
await srv2.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handshake / client registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClientRegistry:
|
||||
async def test_handshake_registers_client(self, server, client):
|
||||
assert server.has_client()
|
||||
assert server.has_client(browser="firefox")
|
||||
assert not server.has_client(browser="chrome")
|
||||
assert len(server.clients) == 1
|
||||
assert server.clients[0].browser == "firefox"
|
||||
assert server.clients[0].client_id == client.client_id
|
||||
|
||||
async def test_handshake_ok_includes_server_id_and_tools(self, server, client):
|
||||
assert client.handshake_ok is not None
|
||||
assert client.handshake_ok["serverId"] == server.server_id
|
||||
assert isinstance(client.handshake_ok["tools"], list)
|
||||
assert any(t["name"] == "browser" for t in client.handshake_ok["tools"])
|
||||
|
||||
async def test_disconnect_removes_client(self, server):
|
||||
c = MockExtensionClient(server.port)
|
||||
await c.connect()
|
||||
await asyncio.sleep(0.05)
|
||||
assert server.has_client()
|
||||
await c.close()
|
||||
# Allow server cleanup
|
||||
await asyncio.sleep(0.1)
|
||||
assert not server.has_client()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RPC dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRpcDispatch:
|
||||
async def test_send_round_trips_to_extension(self, server, client):
|
||||
async def handler(method, params):
|
||||
assert method == "Page.navigate"
|
||||
assert params == {"url": "https://example.com"}
|
||||
return {"frameId": "main"}
|
||||
|
||||
client.response_handler = handler
|
||||
result = await server.send("Page.navigate", {"url": "https://example.com"})
|
||||
assert result == {"frameId": "main"}
|
||||
|
||||
async def test_extension_error_propagates(self, server, client):
|
||||
async def handler(method, params):
|
||||
raise RuntimeError("nope")
|
||||
|
||||
client.response_handler = handler
|
||||
with pytest.raises(RuntimeError, match="nope"):
|
||||
await server.send("hanzo.click", {"selector": "#x"})
|
||||
|
||||
async def test_send_with_browser_filter(self, server):
|
||||
c1 = MockExtensionClient(server.port, browser="firefox", client_id="ff-1")
|
||||
c2 = MockExtensionClient(server.port, browser="chrome", client_id="ch-1")
|
||||
await c1.connect()
|
||||
await c2.connect()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
c1.response_handler = lambda m, p: "from-firefox"
|
||||
c2.response_handler = lambda m, p: "from-chrome"
|
||||
|
||||
try:
|
||||
assert await server.send("any", {}, browser="firefox") == "from-firefox"
|
||||
assert await server.send("any", {}, browser="chrome") == "from-chrome"
|
||||
finally:
|
||||
await c1.close()
|
||||
await c2.close()
|
||||
|
||||
async def test_no_match_raises(self, server, client):
|
||||
# client is firefox; ask for chrome
|
||||
with pytest.raises(RuntimeError, match="No ZAP-connected"):
|
||||
await server.send("any", {}, browser="chrome")
|
||||
|
||||
async def test_ping_pong(self, server, client):
|
||||
# Send ping and verify server echoes pong; just ensure no crash.
|
||||
await client.ws.send(zs.encode(zs.MSG_PING, {}))
|
||||
# Receive pong
|
||||
await asyncio.sleep(0.05)
|
||||
# Server replies with MSG_PONG (we caught it in client.received via _run)
|
||||
types = [t for t, _ in client.received]
|
||||
assert zs.MSG_PONG in types
|
||||
|
||||
async def test_extension_initiated_request_acked(self, server, client):
|
||||
# Extension sends MSG_REQUEST as a notification (server must ack)
|
||||
await client.ws.send(zs.encode(zs.MSG_REQUEST, {"id": "evt-1", "method": "notifications/elementSelected", "params": {"x": 1}}))
|
||||
await asyncio.sleep(0.1)
|
||||
# Find the response
|
||||
responses = [p for t, p in client.received if t == zs.MSG_RESPONSE]
|
||||
assert any(r.get("id") == "evt-1" for r in responses)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution priority
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolution:
|
||||
async def test_resolve_explicit_client_id(self, server):
|
||||
c1 = MockExtensionClient(server.port, browser="firefox", client_id="ff-1")
|
||||
c2 = MockExtensionClient(server.port, browser="firefox", client_id="ff-2")
|
||||
await c1.connect()
|
||||
await c2.connect()
|
||||
await asyncio.sleep(0.05)
|
||||
try:
|
||||
picked = server.resolve_client(client_id="ff-2")
|
||||
assert picked is not None and picked.client_id == "ff-2"
|
||||
finally:
|
||||
await c1.close()
|
||||
await c2.close()
|
||||
|
||||
async def test_default_prefers_firefox(self, server):
|
||||
chrome = MockExtensionClient(server.port, browser="chrome", client_id="ch")
|
||||
firefox = MockExtensionClient(server.port, browser="firefox", client_id="ff")
|
||||
await chrome.connect()
|
||||
await firefox.connect()
|
||||
await asyncio.sleep(0.05)
|
||||
try:
|
||||
picked = server.resolve_client()
|
||||
assert picked is not None and "firefox" in picked.browser
|
||||
finally:
|
||||
await chrome.close()
|
||||
await firefox.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLeases:
|
||||
async def test_claim_and_release(self, server, client):
|
||||
srv2_id = "mcp-py-other"
|
||||
lease = server.claim(client.client_id, ttl=10)
|
||||
assert lease.client_id == client.client_id
|
||||
assert lease.holder == server.server_id
|
||||
assert server.release(client.client_id) is True
|
||||
|
||||
async def test_claim_rejects_when_held_by_other(self, server, client):
|
||||
# First grab from server
|
||||
server.claim(client.client_id, ttl=10)
|
||||
# Synthesise a different "holder" by mutating server_id; this is the
|
||||
# closest we can get without spinning up a second ZapServer.
|
||||
original = server.server_id
|
||||
try:
|
||||
server.server_id = "other-mcp"
|
||||
with pytest.raises(RuntimeError, match="already leased"):
|
||||
server.claim(client.client_id, ttl=10)
|
||||
finally:
|
||||
server.server_id = original
|
||||
|
||||
async def test_release_only_works_for_holder(self, server, client):
|
||||
server.claim(client.client_id, ttl=10)
|
||||
original = server.server_id
|
||||
try:
|
||||
server.server_id = "other-mcp"
|
||||
assert server.release(client.client_id) is False
|
||||
finally:
|
||||
server.server_id = original
|
||||
|
||||
async def test_send_blocked_by_other_holder(self, server, client):
|
||||
# Hold with a different holder
|
||||
from hanzo_tools.browser.zap_server import BrowserLease
|
||||
|
||||
server._leases[client.client_id] = BrowserLease(
|
||||
client_id=client.client_id,
|
||||
holder="someone-else",
|
||||
expires_at=time.time() + 30,
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="leased by"):
|
||||
await server.send("any", {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClusterRegistry:
|
||||
async def test_registry_includes_running_server(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports, agent_label="dev-agent")
|
||||
try:
|
||||
await srv.start()
|
||||
instances = zs.ZapServer.list_mcp_instances()
|
||||
mine = [i for i in instances if i["pid"] == os.getpid()]
|
||||
assert mine, "this MCP missing from registry"
|
||||
assert mine[0]["agent_label"] == "dev-agent"
|
||||
assert mine[0]["port"] == srv.port
|
||||
finally:
|
||||
await srv.stop()
|
||||
|
||||
async def test_unregister_on_stop(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
port = await srv.start()
|
||||
await srv.stop()
|
||||
instances = zs.ZapServer.list_mcp_instances()
|
||||
# The (pid, port) tuple this server occupied must be gone.
|
||||
assert not any(
|
||||
i.get("pid") == os.getpid() and i.get("port") == port for i in instances
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-MCP scenario (the headline use case)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiMcp:
|
||||
async def test_two_mcps_with_one_extension(self, isolated_home, free_ports):
|
||||
"""Two Python ZAP servers running side by side; one mock extension
|
||||
connects to BOTH; each MCP can dispatch independently."""
|
||||
srv_a = zs.ZapServer(ports=free_ports, agent_label="agent-A")
|
||||
srv_b = zs.ZapServer(ports=free_ports, agent_label="agent-B")
|
||||
try:
|
||||
port_a = await srv_a.start()
|
||||
port_b = await srv_b.start()
|
||||
assert port_a is not None and port_b is not None
|
||||
assert port_a != port_b
|
||||
|
||||
client_a = MockExtensionClient(port_a, browser="firefox", client_id="ext-shared")
|
||||
client_b = MockExtensionClient(port_b, browser="firefox", client_id="ext-shared")
|
||||
await client_a.connect()
|
||||
await client_b.connect()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
client_a.response_handler = lambda m, p: {"served_by": "A"}
|
||||
client_b.response_handler = lambda m, p: {"served_by": "B"}
|
||||
|
||||
# Dispatch from each MCP — must NOT cross-talk.
|
||||
r_a = await srv_a.send("any", {})
|
||||
r_b = await srv_b.send("any", {})
|
||||
assert r_a == {"served_by": "A"}
|
||||
assert r_b == {"served_by": "B"}
|
||||
|
||||
# Registry sees both
|
||||
instances = zs.ZapServer.list_mcp_instances()
|
||||
labels = {i.get("agent_label") for i in instances}
|
||||
assert {"agent-A", "agent-B"}.issubset(labels)
|
||||
|
||||
await client_a.close()
|
||||
await client_b.close()
|
||||
finally:
|
||||
await srv_a.stop()
|
||||
await srv_b.stop()
|
||||
Reference in New Issue
Block a user