Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 39 additions & 35 deletions LLM.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,46 +97,50 @@ SQLite-based with optional vector search (sqlite-vec).

## 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).
**hanzo-mcp is a `zapd` *consumer*** — it hosts NO server. It connects to the
one shared local router `~/.zap/run/zapd.sock` (see `~/work/zap`), lists
providers, and routes opaque commands to a `browser:*` provider (the real Chrome/
Firefox extension, connected via the native host). No in-process server, no mDNS,
no `9999-9995`, no `:9224` HTTP bridge, no `BROWSER_TRANSPORT`, no Playwright
fallback in native-browser mode. The old `zap_server.py`/`cdp_bridge_server.py`
in-process model is removed.

```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
)
from hanzo_tools.browser.zapd_consumer import ZapdConsumer, get_consumer
c = get_consumer() # connects to ~/.zap/run/zapd.sock
c.resolve_browser("chrome", None) # -> "browser:chrome/<host>/default"
c.route(provider, "Target.getTargets", {}) # opaque command, raw result bytes
```

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
```
- `hanzo_tools/browser/zapd_consumer.py` — the ZAP router-envelope codec +
consumer (connect / hello / providers.list / route). Mirrors `zapd/src/frame.rs`.
- `hanzo_tools/browser/browser_tool.py` — `_extension_command` / `_check_extension`
route via `zapd_consumer`. `register_browser_tools` no longer starts a server.
- `hanzo_tools/browser/cdp_tool.py` — the `cdp` tool, a method-oriented peer of
`browser`. Sends a raw CDP method by name (`Target.getTargets`, `Page.navigate`)
through the SAME `zapd_consumer` route — the method goes on the wire verbatim,
so there is no `{"action":"cdp"}` envelope for the extension to reject.

Two tools, one transport: `browser` (high-level verbs) and `cdp` (raw methods).
Both are in `TOOLS` and resolve to the same zapd provider.

The router (`zapd`) is a separate always-on daemon — install/run it from
`zap-proto/zapd` (`curl … | sh` or `@hanzo/zapd`).

**MCP must be sourced from disk, not PyPI.** The published `hanzo-tools-browser`
wheel (≤0.5.7) still ships the removed in-process HTTP-bridge model and breaks
`cdp` with `Unknown method: cdp`. The Claude Code MCP entry in `~/.claude.json`
therefore uses `uvx --from pkg/hanzo-mcp --with-editable pkg/hanzo-tools-browser`
so the on-disk source (≥0.5.8) is authoritative. `--with <python-sdk root>` does
NOT work — uv ignores the workspace sources for `--from` deps and pulls the buggy
wheel from the index. `hanzo-mcp` pins `hanzo-tools-browser>=0.5.8` as a guard.

Router/transport tests live with the router (`zap-proto/zapd`: `cargo test` +
`tests/e2e.py`). The Python consumer + `cdp` routing are unit-tested in
`tests/test_browser_tools.py` (zapd mocked) and verified end-to-end against a
live `zapd`. The old `test_zap_server.py`/`zap_server.py` (in-process server,
leases, multi-MCP) and `cdp_bridge_server.py` are removed.

## API Tool

Expand Down
2 changes: 1 addition & 1 deletion pkg/hanzo-mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [
"hanzo-tools>=0.3.0",
"hanzo-tools-fs>=0.1.0",
"hanzo-tools-shell>=0.6.1",
"hanzo-tools-browser[playwright]>=0.5.7", # 3 peer tools (browser/cdp/playwright); 0.5.6 was DOA
"hanzo-tools-browser[playwright]>=0.5.8", # 3 peer tools (browser/cdp/playwright) over zapd consumer (~/.zap/run/zapd.sock)
"hanzo-tools-memory>=0.2.0",
"hanzo-tools-todo>=0.1.0",
"hanzo-tools-reasoning>=0.1.0",
Expand Down
216 changes: 62 additions & 154 deletions pkg/hanzo-tools-browser/hanzo_tools/browser/__init__.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,25 @@
"""Browser automation tools — decomplected surface.

Three orthogonal MCP tools, all default-enabled, all disable-able by env:

* ``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``.

* ``cdp`` — raw Chrome DevTools Protocol method dispatch. Same
transports as ``browser`` minus the Playwright fallback.
Disable with ``HANZO_CDP_TOOL_DISABLED=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``.
"""Browser automation tools for Hanzo AI — zapd consumer model.

[Browser ext] --native host--> [zapd router] --unix sock--> [hanzo-mcp] --stdio--> [Agent]

hanzo-mcp hosts NO server. It connects to the one shared local router at
``~/.zap/run/zapd.sock`` as a *consumer*, lists providers, and routes opaque
CDP commands to a ``browser:*`` provider (the real Chrome/Firefox extension,
connected via its native-messaging host). No in-process server, no mDNS, no
well-known port pool, no :9224 HTTP bridge, no Playwright fallback in
native-browser mode. ``zapd`` is a separate always-on daemon (see ``~/work/zap``).

Three peer tools are exposed, all sharing one transport (``zapd_consumer``):
- ``browser`` — high-level, action-oriented (navigate/click/screenshot/tabs …).
Auto-routes through the extension over zapd; falls back to
Playwright only for actions the extension can't serve.
- ``cdp`` — low-level, method-oriented (send any CDP method by name).
- ``playwright`` — the same action surface as ``browser`` but pinned to the
Playwright backend (never touches the extension).
"""

from __future__ import annotations

import logging
import os
from typing import TYPE_CHECKING
from typing import Optional

from mcp.server import FastMCP

Expand All @@ -48,117 +37,12 @@
)
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,
get_or_start_server,
get_server,
shutdown_server,
)

if TYPE_CHECKING:
from hanzo_tools.browser.cdp_bridge_server import (
CDPBridgeClient,
CDPBridgeServer,
)

# 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]
from hanzo_tools.browser.zapd_consumer import ZapdConsumer, get_consumer

logger = logging.getLogger(__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]] = []

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[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)

# Tools list for entry point discovery (see pyproject [hanzo.tools]).
TOOLS = [BrowserTool, CdpTool, PlaywrightTool]

__all__ = [
# Tools (the three peers)
Expand All @@ -168,24 +52,12 @@ def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
# Factory + module-level instance (existing public API)
"browser_tool",
"create_browser_tool",
# Browser pool
# Browser pool / Playwright server
"BrowserPool",
"launch_browser_server",
# ZAP (canonical)
"ZapServer",
"ZapClient",
"get_or_start_server",
"get_server",
"shutdown_server",
# CDP Bridge (legacy fallback)
"CDPBridgeServer",
"CDPBridgeClient",
"CDP_BRIDGE_AVAILABLE",
"start_cdp_bridge",
"stop_cdp_bridge",
# Lifecycle (now in lifecycle.py)
"ensure_zap_server",
"stop_zap_server",
# zapd consumer (the one canonical transport)
"ZapdConsumer",
"get_consumer",
# Availability check
"PLAYWRIGHT_AVAILABLE",
# Backend helper
Expand All @@ -195,3 +67,39 @@ def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"register_browser_tools",
"register_tools",
]


def register_browser_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register the browser tools with the MCP server.

hanzo-mcp is a zapd *consumer* — it connects to the shared local router at
``~/.zap/run/zapd.sock`` on demand and hosts no in-process server. zapd is a
separate always-on daemon, so there is nothing to start here.

Args:
mcp_server: The FastMCP server instance
**kwargs: Forwarded to the browser tool (headless, cdp_endpoint, backend)

Returns:
List of registered tools
"""
headless = kwargs.get("headless", True)
cdp_endpoint = kwargs.get("cdp_endpoint")
backend = kwargs.get("backend")

tools: list[BaseTool] = [
create_browser_tool(headless=headless, cdp_endpoint=cdp_endpoint, backend=backend),
CdpTool(),
PlaywrightTool(headless=headless, cdp_endpoint=cdp_endpoint),
]
for tool in tools:
ToolRegistry.register_tool(mcp_server, tool)
return tools


def register_tools(mcp_server: FastMCP, **kwargs) -> list[BaseTool]:
"""Register all browser tools with the MCP server.

Standard entry point called by the tool discovery system.
"""
return register_browser_tools(mcp_server, **kwargs)
Loading
Loading