mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
* fix(browser-mcp): migrate CDP bridge -> zapd consumer; make disk authoritative The MCP runtime kept pulling the buggy published wheel (hanzo-tools-browser 0.5.7) from PyPI instead of the migrated on-disk source, so every restart re-broke the `cdp` tool. Reinstate `cdp` as a zapd-native tool, finish the bridge->zapd migration (delete dead bridge/server modules + their tests), bump to 0.5.8 and pin hanzo-mcp >=0.5.8, add the package to the uv workspace/sources so disk is authoritative. 9/9 browser tests pass. Pairs with the hanzoai/extension bridge->zapd migration (separate, still uncommitted). The ~/.claude.json MCP invocation was also pointed at the local editable source (machine config, not in this repo). (cherry picked from commit 11b4d4ea8a5570d6fb0e51079fa445dba3713f71) * fix(browser): drop dead lifecycle.py (in-process zap_server/cdp_bridge are gone) lifecycle.py only wrapped the removed in-process ZAP server + CDP bridge; nothing imports it under the zapd-consumer model. One way: transport is zapd_consumer, tools are browser/cdp/playwright. --------- Co-authored-by: hanzo-dev <[email protected]> Co-authored-by: Antje Worring <[email protected]> Co-authored-by: Hanzo Dev <[email protected]>
196 lines
7.2 KiB
Python
196 lines
7.2 KiB
Python
"""Raw Chrome DevTools Protocol method dispatch — zapd-native.
|
|
|
|
Peer of ``browser`` (action-oriented). ``cdp`` is *method-oriented*: it sends a
|
|
CDP method by name with raw params straight to a connected browser provider over
|
|
the shared local zapd router (``~/.zap/run/zapd.sock``). Same backing transport
|
|
as ``browser`` — no in-process server, no :9224 HTTP bridge, no Playwright
|
|
fallback. The method name goes on the wire verbatim (``Target.getTargets``,
|
|
``Page.navigate`` …) so the extension's CDP dispatch handles it directly; there
|
|
is no ``{"action": "cdp"}`` envelope to misroute.
|
|
|
|
Use ``browser`` for high-level verbs (navigate, click, screenshot).
|
|
Use ``cdp`` when you need a CDP method the high-level surface doesn't expose.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Union, Optional, Annotated
|
|
|
|
from pydantic import Field
|
|
from mcp.server import FastMCP
|
|
|
|
from hanzo_tools.core import BaseTool
|
|
from hanzo_tools.browser.zapd_consumer import get_consumer
|
|
|
|
|
|
# Sugared actions → the bare CDP method they map to.
|
|
_SUGARED: dict[str, str] = {
|
|
"tabs": "Target.getTargets",
|
|
"status": "Browser.getVersion",
|
|
"list_browsers": "", # handled locally via the zapd provider list
|
|
}
|
|
|
|
|
|
async def _route_cdp(
|
|
method: str,
|
|
params: Optional[dict],
|
|
*,
|
|
target_browser: Optional[str] = None,
|
|
tab_id: Union[str, int, None] = None,
|
|
client_id: Optional[str] = None,
|
|
timeout: float = 30.0,
|
|
) -> dict[str, Any]:
|
|
"""Route a raw CDP method to a browser provider via the local zapd router.
|
|
|
|
Mirrors ``browser_tool._extension_command`` but sends the method name
|
|
verbatim instead of mapping a high-level action. One transport, one codec.
|
|
"""
|
|
import asyncio
|
|
|
|
consumer = get_consumer()
|
|
if consumer is None:
|
|
return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap", "method": method}
|
|
|
|
try:
|
|
provider = await asyncio.to_thread(consumer.resolve_browser, target_browser, client_id)
|
|
except Exception as e:
|
|
return {"error": str(e), "transport": "native-zap", "method": method}
|
|
if not provider:
|
|
return {"error": "no browser provider connected over zapd", "transport": "native-zap", "method": method}
|
|
|
|
wire: dict[str, Any] = dict(params or {})
|
|
if tab_id is not None:
|
|
wire.setdefault("tabId", tab_id)
|
|
str_params = {k: (v if isinstance(v, str) else str(v)) for k, v in wire.items() if v is not None}
|
|
|
|
try:
|
|
raw = await asyncio.to_thread(consumer.route, provider, method, str_params, timeout)
|
|
except Exception as e:
|
|
return {"error": str(e), "transport": "native-zap", "method": method}
|
|
|
|
text = raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else raw
|
|
return {"success": True, "transport": "native-zap", "source": "zapd", "provider": provider, "method": method, "result": text}
|
|
|
|
|
|
async def _list_browsers() -> dict[str, Any]:
|
|
"""List browser providers connected to the local zapd router."""
|
|
import asyncio
|
|
|
|
consumer = get_consumer()
|
|
if consumer is None:
|
|
return {"error": "zapd not reachable (~/.zap/run/zapd.sock)", "transport": "native-zap"}
|
|
try:
|
|
provs = await asyncio.to_thread(consumer.list_providers)
|
|
except Exception as e:
|
|
return {"error": str(e), "transport": "native-zap"}
|
|
browsers = [p for p in provs if p.get("id", "").startswith("browser:")]
|
|
return {"success": True, "transport": "native-zap", "browsers": browsers, "count": len(browsers)}
|
|
|
|
|
|
CdpAction = Annotated[
|
|
str,
|
|
Field(description="CDP action: send | tabs | status | list_browsers"),
|
|
]
|
|
|
|
|
|
class CdpTool(BaseTool):
|
|
"""Raw Chrome DevTools Protocol method dispatch — peer of ``browser``."""
|
|
|
|
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
|
|
|
|
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).
|
|
"""
|
|
|
|
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",
|
|
method: Optional[str] = None,
|
|
params: Optional[dict] = None,
|
|
tab_id: Optional[Union[str, int]] = None,
|
|
target_browser: Optional[str] = None,
|
|
client_id: Optional[str] = None,
|
|
timeout: Optional[float] = None,
|
|
) -> dict[str, Any]:
|
|
t = float(timeout) if timeout else 30.0
|
|
|
|
if action == "list_browsers":
|
|
return await _list_browsers()
|
|
|
|
if action in _SUGARED:
|
|
return await _route_cdp(
|
|
_SUGARED[action], {},
|
|
target_browser=target_browser, tab_id=tab_id, client_id=client_id, timeout=t,
|
|
)
|
|
|
|
if action == "send":
|
|
if not method:
|
|
return {"error": "method required for action=send (e.g. 'Page.navigate')", "action": "send"}
|
|
return await _route_cdp(
|
|
method, params,
|
|
target_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"}
|
|
|
|
def register(self, mcp_server: FastMCP) -> None:
|
|
"""Register the cdp tool with an MCP server."""
|
|
tool_instance = self
|
|
|
|
@mcp_server.tool(name=self.name, description=self.description)
|
|
async def cdp(
|
|
action: CdpAction = "send",
|
|
method: Annotated[
|
|
Optional[str],
|
|
Field(description="CDP method name (e.g. 'Page.navigate', 'Runtime.evaluate')"),
|
|
] = None,
|
|
params: Annotated[
|
|
Optional[dict],
|
|
Field(description="CDP method params"),
|
|
] = None,
|
|
tab_id: Annotated[
|
|
Optional[Union[str, int]],
|
|
Field(description="Target tab id (string or int)"),
|
|
] = None,
|
|
target_browser: Annotated[
|
|
Optional[str],
|
|
Field(description="Provider filter: firefox|chrome|safari|edge"),
|
|
] = None,
|
|
client_id: Annotated[
|
|
Optional[str],
|
|
Field(description="Specific extension client id"),
|
|
] = None,
|
|
timeout: Annotated[
|
|
Optional[float],
|
|
Field(description="Per-call timeout (seconds)"),
|
|
] = None,
|
|
) -> str:
|
|
result = await tool_instance.execute(
|
|
action=action,
|
|
method=method,
|
|
params=params,
|
|
tab_id=tab_id,
|
|
target_browser=target_browser,
|
|
client_id=client_id,
|
|
timeout=timeout,
|
|
)
|
|
return json.dumps(result, indent=2, default=str)
|