hanzo-mcp 0.15.3 + hanzo-tools-browser 0.5.4: ZAP TextContent normalization, mDNS-only discovery

hanzo-mcp (0.15.1 -> 0.15.3):
- _normalize_mcp_result() collapses FastMCP list[TextContent] to parsed JSON
  for ZAP MSG_RESPONSE serialization
- Closes the "TextContent not JSON serializable" bug surfaced when driving
  the Hanzo extension from Claude Code via tools/call
- Bump hanzo-tools-browser[playwright] floor to 0.5.4

hanzo-tools-browser (0.5.2 -> 0.5.4):
- mDNS-only ZAP discovery via _hanzo._tcp.local. (HIP-0069); no port pool,
  no lockfile, OS-assigned ports advertised by hanzo-zap-mdns
- zap_server.py: refactor for unique server_id + retract on stop
- Test suite trimmed to the new wire contract

Patch-bump both packages.
This commit is contained in:
2026-05-10 17:17:13 -07:00
parent 9615829240
commit a7501cb837
7 changed files with 265 additions and 719 deletions
+49 -2
View File
@@ -34,6 +34,48 @@ _register_all_tools = None
_register_all_prompts = None _register_all_prompts = None
def _normalize_mcp_result(result: Any) -> Any:
"""Convert FastMCP tool results to JSON-serialisable primitives for ZAP.
FastMCP returns ``list[mcp.types.TextContent | ImageContent | ...]``.
Pydantic models in that list have ``model_dump()`` (v2) or ``dict()``
(v1). For TextContent specifically, the underlying text is often itself
a JSON document — collapse single-element TextContent lists to the
parsed JSON when possible to minimise wire shape juggling for clients.
"""
import json as _json
def _one(item: Any) -> Any:
if isinstance(item, (str, int, float, bool)) or item is None:
return item
if isinstance(item, (list, tuple)):
return [_one(x) for x in item]
if isinstance(item, dict):
return {k: _one(v) for k, v in item.items()}
dump = getattr(item, "model_dump", None)
if callable(dump):
return dump(mode="json")
d = getattr(item, "dict", None)
if callable(d):
return d()
return str(item)
norm = _one(result)
if (
isinstance(norm, list)
and len(norm) == 1
and isinstance(norm[0], dict)
and norm[0].get("type") == "text"
and isinstance(norm[0].get("text"), str)
):
text = norm[0]["text"]
try:
return _json.loads(text)
except Exception:
return {"type": "text", "text": text}
return norm
def _get_fast_mcp(): def _get_fast_mcp():
"""Get FastMCP class lazily.""" """Get FastMCP class lazily."""
global _FastMCP global _FastMCP
@@ -388,12 +430,17 @@ class HanzoMCPServer:
pass pass
async def call_tool(name: str, args: dict) -> Any: # type: ignore[type-arg] async def call_tool(name: str, args: dict) -> Any: # type: ignore[type-arg]
"""Route ZAP tool calls to the MCP server.""" """Route ZAP tool calls to the MCP server.
FastMCP returns a list of ``mcp.types.TextContent`` (or other
content) objects. Normalize to JSON-serialisable primitives so
``zap.protocol.encode`` can serialize the MSG_RESPONSE body.
"""
try: try:
result = await self.mcp.call_tool(name, args) result = await self.mcp.call_tool(name, args)
return result
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
return _normalize_mcp_result(result)
async def handle_method(method: str, params: Any) -> Any: async def handle_method(method: str, params: Any) -> Any:
"""Pass-through for ALL MCP methods — full protocol parity over ZAP. """Pass-through for ALL MCP methods — full protocol parity over ZAP.
+66 -275
View File
@@ -1,296 +1,87 @@
"""ZAP (Zero-copy Agent Protocol) Server for hanzo-mcp. """ZAP server adaptor for hanzo-mcp.
Allows Hanzo browser extensions and agents to discover this MCP server This module is a thin pass-through to ``hanzo_tools.browser.zap_server``,
and call tools directly over the native ZAP binary protocol. which is the single source of truth for the ZAP wire protocol and the
WebSocket server hosted inside every hanzo-mcp process. Discovery is
mDNS-only (HIP-0069); the OS picks the port, mDNS carries it.
Uses the ``zap-protocol`` package for wire format and transport, There is one and only one ZapServer implementation in the Python stack —
adding hanzo-mcp-specific request routing on top. this module exists purely so existing callers that import
``hanzo_mcp.zap_server`` resolve to the canonical class.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
import time
from typing import Any, Awaitable, Callable
from zap.protocol import ( from hanzo_tools.browser.zap_server import (
HEADER_SIZE, ZapServer,
MAX_MESSAGE_SIZE, get_or_start_server,
MSG_HANDSHAKE, get_server,
MSG_HANDSHAKE_OK, shutdown_server,
MSG_PING,
MSG_PONG,
MSG_REQUEST,
MSG_RESPONSE,
ZAP_MAGIC,
ZAP_PORTS,
decode,
encode,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Client tracking ─────────────────────────────────────────────────────
class ZapClient:
__slots__ = ("writer", "client_id", "browser", "version", "connected_at")
def __init__(
self,
writer: asyncio.StreamWriter,
client_id: str = "unknown",
browser: str = "unknown",
version: str = "0",
) -> None:
self.writer = writer
self.client_id = client_id
self.browser = browser
self.version = version
self.connected_at = time.time()
# ── ZAP TCP Server ──────────────────────────────────────────────────────
class ZapServer:
"""ZAP TCP server for agent and browser extension discovery.
Provides full MCP protocol parity — any MCP method (tools/*, resources/*,
prompts/*) can be called over the ZAP binary transport via the
handle_method pass-through.
"""
def __init__(
self,
tools: list[dict[str, Any]],
call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]],
handle_method: Callable[[str, Any], Awaitable[Any]] | None = None,
name: str = "hanzo-mcp",
) -> None:
self.tools = tools
self.call_tool = call_tool
self.handle_method = handle_method
self.name = name
self.server_id = f"mcp-{int(time.time()):x}"
self.clients: dict[str, ZapClient] = {}
self._server: asyncio.Server | None = None
self.port: int | None = None
self.tool_manifest = [
{
"name": t.get("name", ""),
"description": t.get("description", ""),
"inputSchema": t.get("inputSchema", t.get("input_schema", {})),
}
for t in self.tools
]
async def _read_frame(self, reader: asyncio.StreamReader) -> tuple[int, Any] | None:
"""Read a single ZAP frame from the stream."""
try:
header = await reader.readexactly(HEADER_SIZE)
except (asyncio.IncompleteReadError, ConnectionError):
return None
if header[:4] != ZAP_MAGIC:
return None
msg_type = header[4]
import struct
length = struct.unpack("!L", header[5:9])[0]
if length > MAX_MESSAGE_SIZE:
return None
try:
payload_bytes = await reader.readexactly(length)
except (asyncio.IncompleteReadError, ConnectionError):
return None
import json
try:
payload = json.loads(payload_bytes.decode("utf-8"))
return (msg_type, payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
async def _send_frame(
self, writer: asyncio.StreamWriter, msg_type: int, payload: Any
) -> None:
"""Send a ZAP frame to the stream."""
frame = encode(msg_type, payload)
writer.write(frame)
await writer.drain()
async def _handle_connection(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
"""Handle a single TCP connection."""
peer = writer.get_extra_info("peername")
client: ZapClient | None = None
client_key = f"{peer[0]}:{peer[1]}" if peer else f"unknown-{time.time()}"
try:
while True:
result = await self._read_frame(reader)
if result is None:
break
msg_type, payload = result
if msg_type == MSG_HANDSHAKE:
client_id = payload.get("clientId", "unknown")
browser = payload.get("browser", "unknown")
version = payload.get("version", "0")
client = ZapClient(
writer=writer,
client_id=client_id,
browser=browser,
version=version,
)
self.clients[client_key] = client
logger.info(
f"[ZAP] Client connected: {client_id} ({browser} v{version})"
)
await self._send_frame(
writer,
MSG_HANDSHAKE_OK,
{
"serverId": self.server_id,
"name": self.name,
"tools": self.tool_manifest,
},
)
elif msg_type == MSG_REQUEST:
req_id = payload.get("id", "")
method = payload.get("method", "")
params = payload.get("params", {})
await self._handle_request(writer, req_id, method, params)
elif msg_type == MSG_PING:
await self._send_frame(writer, MSG_PONG, {})
except Exception as e:
logger.debug(f"[ZAP] Connection error: {e}")
finally:
if client_key in self.clients:
client = self.clients.pop(client_key)
logger.info(f"[ZAP] Client disconnected: {client.client_id}")
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
async def _handle_request(
self, writer: asyncio.StreamWriter, req_id: str, method: str, params: Any
) -> None:
"""Handle a ZAP RPC request."""
try:
if method == "tools/list":
result = {"tools": self.tool_manifest}
elif method == "tools/call":
tool_name = (params or {}).get("name", "")
args = (params or {}).get("arguments", {})
if not tool_name:
raise ValueError("Missing tool name")
result = await self.call_tool(tool_name, args)
elif method.startswith("notifications/"):
result = {"acknowledged": True}
else:
if self.handle_method:
result = await self.handle_method(method, params)
else:
raise ValueError(f"Unsupported method: {method}")
await self._send_frame(
writer, MSG_RESPONSE, {"id": req_id, "result": result}
)
except Exception as e:
await self._send_frame(
writer,
MSG_RESPONSE,
{
"id": req_id,
"error": {"code": -1, "message": str(e)},
},
)
async def start(self, preferred_port: int | None = None) -> bool:
"""Start the ZAP server on the first available TCP port."""
ports = (
[preferred_port, *[p for p in ZAP_PORTS if p != preferred_port]]
if preferred_port
else list(ZAP_PORTS)
)
for port in ports:
try:
self._server = await asyncio.start_server(
self._handle_connection,
"127.0.0.1",
port,
)
self.port = port
logger.info(
f"[ZAP] Server listening on zap://127.0.0.1:{port} "
f"({len(self.tool_manifest)} tools)"
)
return True
except OSError:
continue
logger.warning(
"[ZAP] Could not bind to any port (9999-9995). ZAP discovery disabled."
)
return False
def stop(self) -> None:
"""Stop the ZAP server."""
if self._server:
self._server.close()
self._server = None
self.port = None
for client in list(self.clients.values()):
try:
client.writer.close()
except Exception:
pass
self.clients.clear()
async def start_zap_server( async def start_zap_server(
tools: list[dict[str, Any]], tools,
call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]], call_tool,
handle_method: Callable[[str, Any], Awaitable[Any]] | None = None, handle_method=None,
name: str = "hanzo-mcp", name: str = "hanzo-mcp",
preferred_port: int | None = None, preferred_port=None, # noqa: ARG001 — kept for legacy callers, ignored
) -> ZapServer | None: ):
"""Create and start a ZAP server. """Start (or reuse) the canonical ZAP server and wire MCP routing.
Args: Args:
tools: List of tool dicts with name, description, inputSchema. tools: List of tool dicts with name/description/inputSchema.
call_tool: Async callable (name, args) -> result to route tool calls. call_tool: Async (name, args) -> result for tools/call dispatch.
handle_method: Optional async callable (method, params) -> result for handle_method: Optional async (method, params) -> result for
full MCP parity. full MCP-method parity beyond tools/list and tools/call.
name: Server name for handshake. name: Server name used in the handshake.
preferred_port: Preferred port to try first. preferred_port: Ignored — kept for API parity. Discovery is
mDNS-only; the OS picks the port.
Returns: Returns:
ZapServer instance if started, None otherwise. The live ZapServer, or ``None`` if it could not start
(websockets / zap-mdns missing).
""" """
server = ZapServer( server = await get_or_start_server(agent_label=name)
tools=tools, call_tool=call_tool, handle_method=handle_method, name=name if server is None:
return None
server.set_tools(list(tools))
async def _on_request(method, params):
if handle_method is not None:
try:
return await handle_method(method, params)
except ValueError:
# Fall through to defaults below.
pass
if method == "tools/list":
return {"tools": list(tools)}
if method == "tools/call":
tool_name = (params or {}).get("name", "")
args = (params or {}).get("arguments", {}) or {}
if not tool_name:
raise ValueError("Missing tool name")
return await call_tool(tool_name, args)
if method.startswith("notifications/"):
return {"acknowledged": True}
raise ValueError(f"unsupported method: {method}")
server.set_request_handler(_on_request)
logger.info(
"hanzo-mcp ZAP transport up on :%d (%d tools)", server.port, len(list(tools))
) )
ok = await server.start(preferred_port=preferred_port) return server
return server if ok else None
__all__ = [
"ZapServer",
"start_zap_server",
"get_or_start_server",
"get_server",
"shutdown_server",
]
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hanzo-mcp" name = "hanzo-mcp"
version = "0.15.1" version = "0.15.3"
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others." description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -20,7 +20,7 @@ dependencies = [
# Hanzo AI core SDK (canonical types, config, session, MCP client) # Hanzo AI core SDK (canonical types, config, session, MCP client)
"hanzoai>=2.2.0", "hanzoai>=2.2.0",
# ZAP native protocol # ZAP native protocol
"zap-protocol>=0.3.0", "zap-protocol>=0.2.1",
# Core MCP dependencies # Core MCP dependencies
"mcp>=1.25.0", "mcp>=1.25.0",
"fastmcp>=2.14.4", "fastmcp>=2.14.4",
@@ -34,7 +34,7 @@ dependencies = [
"hanzo-tools>=0.3.0", "hanzo-tools>=0.3.0",
"hanzo-tools-fs>=0.1.0", "hanzo-tools-fs>=0.1.0",
"hanzo-tools-shell>=0.6.1", "hanzo-tools-shell>=0.6.1",
"hanzo-tools-browser[playwright]>=0.4.5", # Extension-first routing, Playwright fallback "hanzo-tools-browser[playwright]>=0.5.4", # mDNS-only ZAP discovery, OS-assigned ports + uniqued server_id
"hanzo-tools-memory>=0.2.0", "hanzo-tools-memory>=0.2.0",
"hanzo-tools-todo>=0.1.0", "hanzo-tools-todo>=0.1.0",
"hanzo-tools-reasoning>=0.1.0", "hanzo-tools-reasoning>=0.1.0",
@@ -4,9 +4,10 @@ Two-process architecture (since 0.5.0):
[Browser ext] --ZAP/WS--> [hanzo-mcp (this Python pkg)] --stdio--> [Agent] [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 The hanzo-mcp process hosts a ZAP server on an OS-assigned ephemeral port
[9999, 9998, 9997, 9996, 9995]. Browser extensions discover and connect to and advertises it via mDNS under ``_hanzo._tcp.local.`` (HIP-0069).
it directly. No node bridge is in the critical path. Browser extensions browse mDNS and connect directly. No node bridge is
in the critical path, no well-known port pool, no lockfile arbitration.
Legacy HTTP bridge (cdp_bridge_server.py on :9223/9224) is retained as an Legacy HTTP bridge (cdp_bridge_server.py on :9223/9224) is retained as an
opt-in fallback for non-ZAP MCP clients. Enable with opt-in fallback for non-ZAP MCP clients. Enable with
@@ -34,7 +35,6 @@ from hanzo_tools.browser.browser_tool import (
launch_browser_server, launch_browser_server,
) )
from hanzo_tools.browser.zap_server import ( from hanzo_tools.browser.zap_server import (
DEFAULT_ZAP_PORTS,
ZapClient, ZapClient,
ZapServer, ZapServer,
get_or_start_server, get_or_start_server,
@@ -80,7 +80,6 @@ __all__ = [
# ZAP (canonical, since 0.5.0) # ZAP (canonical, since 0.5.0)
"ZapServer", "ZapServer",
"ZapClient", "ZapClient",
"DEFAULT_ZAP_PORTS",
"get_or_start_server", "get_or_start_server",
"get_server", "get_server",
"shutdown_server", "shutdown_server",
@@ -99,7 +98,7 @@ __all__ = [
] ]
def _run_zap_server(host: str, ports: list[int]) -> None: def _run_zap_server(host: str) -> None:
"""Run the ZAP server in a dedicated background thread. """Run the ZAP server in a dedicated background thread.
The MCP's main loop is owned by FastMCP/stdio; we keep ZAP isolated so The MCP's main loop is owned by FastMCP/stdio; we keep ZAP isolated so
@@ -114,7 +113,6 @@ def _run_zap_server(host: str, ports: list[int]) -> None:
async def _bootstrap() -> None: async def _bootstrap() -> None:
srv = await get_or_start_server( srv = await get_or_start_server(
host=host, host=host,
ports=ports,
agent_label=os.environ.get("HANZO_AGENT_LABEL"), agent_label=os.environ.get("HANZO_AGENT_LABEL"),
) )
if _zap_started_event is not None: if _zap_started_event is not None:
@@ -145,16 +143,11 @@ def _ensure_zap_server() -> bool:
return False return False
host = os.environ.get("HANZO_ZAP_HOST", "127.0.0.1") 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_started_event = threading.Event()
_zap_thread = threading.Thread( _zap_thread = threading.Thread(
target=_run_zap_server, target=_run_zap_server,
args=(host, ports), args=(host,),
daemon=True, daemon=True,
name="hanzo-zap-server", name="hanzo-zap-server",
) )
@@ -1,41 +1,25 @@
"""ZAP (Zero-latency Agent Protocol) server for hanzo-tools-browser. """ZAP (Zero-latency Agent Protocol) server for hanzo-tools-browser.
Wire format (compatible with @hanzo/browser-extension/src/shared/zap.ts): Wire format and constants come from the canonical ``zap-protocol`` package
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload] so every implementation in the stack stays byte-identical.
Message types: Discovery is mDNS-only per HIP-0069: the server binds an OS-assigned
MSG_HANDSHAKE = 0x01 client -> server ephemeral port and advertises it under ``_hanzo._tcp.local.`` via
MSG_HANDSHAKE_OK = 0x02 server -> client ``zap-mdns``. There is no well-known port pool, no lockfile arbitration,
MSG_REQUEST = 0x10 bidirectional and no shared config registry — clients (browser extensions, sibling
MSG_RESPONSE = 0x11 bidirectional MCPs, agents) browse mDNS to find every live ZAP service on the LAN.
MSG_PING = 0xFE
MSG_PONG = 0xFF
Each hanzo-mcp process runs ONE ZapServer that: One ZapServer per hanzo-mcp process. Lifetime = MCP lifetime.
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 from __future__ import annotations
import asyncio import asyncio
import errno
import fcntl
import json
import logging import logging
import os import os
import socket
import struct
import time import time
import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable, Optional from typing import Any, Awaitable, Callable, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,7 +37,8 @@ from zap.protocol import (
MSG_RESPONSE, MSG_RESPONSE,
MSG_PING, MSG_PING,
MSG_PONG, MSG_PONG,
ZAP_PORTS as DEFAULT_ZAP_PORTS, encode,
decode,
) )
# Browser-resolution preference (mirror cdp-bridge-server.ts) # Browser-resolution preference (mirror cdp-bridge-server.ts)
@@ -63,111 +48,6 @@ DEFAULT_BROWSER_PREFERENCE: list[str] = ["firefox", "safari", "edge", "chrome"]
DEFAULT_LEASE_TTL = 60.0 DEFAULT_LEASE_TTL = 60.0
# Wire encode/decode are imported from zap-protocol — see header import block.
from zap.protocol import encode, decode # noqa: F401 (re-exported for callers)
# ---------------------------------------------------------------------------
# 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 # Client tracking
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -224,20 +104,21 @@ class ZapServer:
def __init__( def __init__(
self, self,
host: str = "127.0.0.1", host: str = "127.0.0.1",
ports: Optional[list[int]] = None,
agent_label: Optional[str] = None, agent_label: Optional[str] = None,
server_id: Optional[str] = None, server_id: Optional[str] = None,
request_timeout: float = 30.0, request_timeout: float = 30.0,
): ):
self.host = host self.host = host
self.ports = list(ports or DEFAULT_ZAP_PORTS)
self.agent_label = agent_label or os.environ.get("HANZO_AGENT_LABEL", "") self.agent_label = agent_label or os.environ.get("HANZO_AGENT_LABEL", "")
self.server_id = server_id or f"mcp-py-{os.getpid()}" # server_id is `mcp-py-<pid>-<4hex>`. PID alone collides across hosts on
# the same LAN; the random suffix makes the name globally unique so
# zeroconf never has to auto-rename to "(2)".
self.server_id = server_id or f"mcp-py-{os.getpid()}-{uuid.uuid4().hex[:4]}"
self.request_timeout = request_timeout self.request_timeout = request_timeout
self._port: Optional[int] = None self._port: Optional[int] = None
self._lock_fd: Optional[int] = None
self._server: Any = None # websockets server self._server: Any = None # websockets server
self._mdns_handle: Any = None
self._clients: dict[str, ZapClient] = {} self._clients: dict[str, ZapClient] = {}
self._ws_to_id: dict[Any, str] = {} self._ws_to_id: dict[Any, str] = {}
self._pending: dict[str, asyncio.Future] = {} self._pending: dict[str, asyncio.Future] = {}
@@ -258,89 +139,77 @@ class ZapServer:
# ---- lifecycle ------------------------------------------------------ # ---- lifecycle ------------------------------------------------------
async def start(self) -> Optional[int]: async def start(self) -> Optional[int]:
"""Bind to the lowest-numbered free port. Returns port or None.""" """Bind to an OS-assigned port and advertise it via mDNS.
Discovery is mDNS-only (HIP-0069); the OS picks the port, mDNS
carries it. No well-known port pool, no lockfile arbitration —
every MCP gets its own ephemeral port and is found by browsing
``_hanzo._tcp.local.``.
Returns the bound port, or ``None`` if either ``websockets`` or
``zap-mdns`` is missing.
"""
try: try:
import websockets # noqa: F401 import websockets # noqa: F401
except ImportError: except ImportError:
logger.warning("websockets not installed; ZAP server disabled") logger.warning("websockets not installed; ZAP server disabled")
return None return None
try:
for port in self.ports: import zap_mdns
fd = _claim_port_lock(port) except ImportError:
if fd is None: logger.error(
logger.debug("zap port %d already locked by another mcp", port) "zap-mdns not installed; the server is unreachable without it. "
continue "`pip install zap-mdns`."
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 "?",
) )
# Best-effort mDNS publish so LAN-wide consumers can find us return None
# without hard-coded ports. No-op if `hanzo-zap-mdns` (which
# owns the zeroconf dep) isn't installed.
self._mdns_handle = None
try:
import zap_mdns
# zeroconf's register_service blocks the event loop briefly
# while it sets up the multicast socket; from inside an
# asyncio coroutine that triggers EventLoopBlocked. Run on
# a worker thread so it never sees our loop.
self._mdns_handle = await asyncio.to_thread(
zap_mdns.publish,
port=port,
server_id=self.server_id,
agent_label=self.agent_label or "",
version="zap/1",
capabilities=["mcp", "browser-bridge"],
)
logger.info("mDNS published %s on :%d", hanzo_zap_mdns.SERVICE_TYPE, port)
except ImportError:
logger.debug("hanzo-zap-mdns not installed; mDNS publish skipped")
except Exception as _e:
logger.warning("mDNS publish failed: %s: %s", type(_e).__name__, _e)
return port
logger.warning("ZAP: no free port in %s", self.ports) from websockets.asyncio.server import serve as _serve
return None
async def _handler(websocket):
await self._handle_connection(websocket)
# Bind to port 0 → OS picks an ephemeral port. The actual port
# comes back via the server's sockets attribute.
self._server = await _serve(_handler, self.host, 0)
sockets = getattr(self._server, "sockets", None) or []
if not sockets:
logger.error("zap: server has no sockets after start()")
return None
self._port = sockets[0].getsockname()[1]
logger.info(
"ZAP server listening on ws://%s:%d (mcp=%s, agent=%s)",
self.host, self._port, self.server_id, self.agent_label or "?",
)
# mDNS publish — the only way clients find this server.
# zeroconf.register_service blocks briefly setting up multicast,
# so run it on a worker thread to avoid asyncio EventLoopBlocked.
try:
self._mdns_handle = await asyncio.to_thread(
zap_mdns.publish,
port=self._port,
server_id=self.server_id,
agent_label=self.agent_label or "",
version="zap/1",
capabilities=["mcp", "browser-bridge"],
)
logger.info("mDNS published %s on :%d", zap_mdns.SERVICE_TYPE, self._port)
except Exception as e:
logger.warning("mDNS publish failed: %s: %s", type(e).__name__, e)
self._mdns_handle = None
return self._port
async def stop(self) -> None: async def stop(self) -> None:
"""Gracefully shut down: close clients, release lock, drop registry entry.""" """Gracefully shut down: retract mDNS, close clients, drop sockets."""
# Retract mDNS announcement first so consumers see us go away. # Retract mDNS announcement first so consumers see us go away.
try: if self._mdns_handle is not None:
if getattr(self, "_mdns_handle", None) is not None: try:
self._mdns_handle.close() self._mdns_handle.close()
self._mdns_handle = None except Exception:
except Exception: pass
pass self._mdns_handle = None
if self._server is not None: if self._server is not None:
self._server.close() self._server.close()
try: try:
@@ -356,10 +225,6 @@ class ZapServer:
pass pass
self._clients.clear() self._clients.clear()
self._ws_to_id.clear() self._ws_to_id.clear()
self._unregister_from_config()
_release_port_lock(self._lock_fd)
self._lock_fd = None
self._port = None self._port = None
# ---- public API ----------------------------------------------------- # ---- public API -----------------------------------------------------
@@ -493,60 +358,29 @@ class ZapServer:
return True return True
return False return False
# ---- registry (cross-MCP visibility) ------------------------------- # ---- cluster discovery ---------------------------------------------
# Cross-MCP visibility comes from mDNS, not a shared file. Use
def _register_in_config(self) -> None: # ``zap_mdns.browse()`` to enumerate live MCPs on the LAN.
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 @staticmethod
def list_mcp_instances() -> list[dict]: def list_mcp_instances(timeout: float = 1.5) -> list[dict]:
"""Read the cluster registry (filters dead pids).""" """Browse ``_hanzo._tcp.local.`` for every live ZAP service."""
cfg = _read_config() try:
instances = cfg.get("mcp_instances", []) import zap_mdns
return [i for i in instances if _pid_alive(i.get("pid"))] except ImportError:
return []
return [
{
"server_id": s.server_id,
"host": s.host,
"port": s.port,
"url": s.url,
"agent_label": s.agent_label,
"version": s.version,
"capabilities": list(s.capabilities or []),
}
for s in zap_mdns.browse(timeout=timeout)
]
# ---- ws handler ----------------------------------------------------- # ---- ws handler -----------------------------------------------------
@@ -669,22 +503,6 @@ class ZapServer:
return f"py-{self._req_counter}" 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) # Process-wide singleton (one ZAP server per hanzo-mcp)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -697,18 +515,17 @@ _singleton_lock = asyncio.Lock()
async def get_or_start_server( async def get_or_start_server(
*, *,
host: str = "127.0.0.1", host: str = "127.0.0.1",
ports: Optional[list[int]] = None,
agent_label: Optional[str] = None, agent_label: Optional[str] = None,
) -> Optional[ZapServer]: ) -> Optional[ZapServer]:
"""Return the process-wide ZAP server, starting it if needed. """Return the process-wide ZAP server, starting it if needed.
Returns ``None`` if no port could be bound (other MCPs hold all 5). Returns ``None`` if either ``websockets`` or ``zap-mdns`` is missing.
""" """
global _singleton global _singleton
async with _singleton_lock: async with _singleton_lock:
if _singleton is not None and _singleton.port is not None: if _singleton is not None and _singleton.port is not None:
return _singleton return _singleton
srv = ZapServer(host=host, ports=ports, agent_label=agent_label) srv = ZapServer(host=host, agent_label=agent_label)
port = await srv.start() port = await srv.start()
if port is None: if port is None:
return None return None
@@ -733,7 +550,6 @@ __all__ = [
"ZapClient", "ZapClient",
"ZapServer", "ZapServer",
"BrowserLease", "BrowserLease",
"DEFAULT_ZAP_PORTS",
"DEFAULT_BROWSER_PREFERENCE", "DEFAULT_BROWSER_PREFERENCE",
"DEFAULT_LEASE_TTL", "DEFAULT_LEASE_TTL",
"MSG_HANDSHAKE", "MSG_HANDSHAKE",
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hanzo-tools-browser" name = "hanzo-tools-browser"
version = "0.5.2" version = "0.5.4"
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." description = "Browser automation tools with ZAP-native extension routing — Python MCP hosts the ZAP server on an OS-assigned port and advertises via mDNS (HIP-0069). No node bridge, no port pool, no lockfile."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { text = "MIT" } license = { text = "MIT" }
+40 -141
View File
@@ -2,21 +2,18 @@
Covers: Covers:
- Wire format encode/decode round-trip and parity with shared/zap.ts. - Wire format encode/decode round-trip and parity with shared/zap.ts.
- Server bind, port-arbitration via flock (multi-MCP coexistence). - Server bind on OS-assigned port (HIP-0069: mDNS-only discovery).
- Mock extension client: register, send response to RPC, ping/pong. - Mock extension client: register, send response to RPC, ping/pong.
- Browser resolution priority (client_id > browser > default preference). - Browser resolution priority (client_id > browser > default preference).
- Browser leases (claim / release / reject when held by other holder). - Browser leases (claim / release / reject when held by other holder).
- Cluster registry in ~/.hanzo/extension/config.json.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
import os
import struct import struct
import time import time
from pathlib import Path
import pytest import pytest
import websockets import websockets
@@ -43,15 +40,14 @@ class TestWireFormat:
assert zs.MSG_PING == 0xFE assert zs.MSG_PING == 0xFE
assert zs.MSG_PONG == 0xFF 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): def test_encode_layout(self):
from zap.protocol import HEADER_SIZE
frame = zs.encode(zs.MSG_REQUEST, {"id": "x", "method": "y"}) frame = zs.encode(zs.MSG_REQUEST, {"id": "x", "method": "y"})
assert frame[:4] == zs.ZAP_MAGIC assert frame[:4] == zs.ZAP_MAGIC
assert frame[4] == zs.MSG_REQUEST assert frame[4] == zs.MSG_REQUEST
(length,) = struct.unpack(">I", frame[5:9]) (length,) = struct.unpack(">I", frame[5:9])
assert length == len(frame) - zs.HEADER_SIZE assert length == len(frame) - HEADER_SIZE
assert json.loads(frame[9:].decode()) == {"id": "x", "method": "y"} assert json.loads(frame[9:].decode()) == {"id": "x", "method": "y"}
def test_round_trip(self): def test_round_trip(self):
@@ -168,42 +164,14 @@ class MockExtensionClient:
@pytest.fixture @pytest.fixture
def isolated_home(tmp_path, monkeypatch): async def server():
"""Sandbox ~/.hanzo so tests never collide with real user state.""" """A live ZapServer on an OS-assigned port. mDNS publish is best-effort
monkeypatch.setenv("HOME", str(tmp_path)) — if zap-mdns is unavailable the start() returns None, and the test
yield tmp_path is skipped (CI without zeroconf shouldn't hard-fail)."""
srv = zs.ZapServer()
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() port = await srv.start()
assert port is not None, "ZAP server failed to bind" if port is None:
pytest.skip("ZapServer.start() returned None — zap-mdns/websockets missing")
yield srv yield srv
await srv.stop() await srv.stop()
@@ -224,50 +192,37 @@ async def client(server):
class TestServerLifecycle: class TestServerLifecycle:
async def test_binds_on_first_free_port(self, isolated_home, free_ports): async def test_binds_on_ephemeral_port(self):
srv = zs.ZapServer(ports=free_ports) srv = zs.ZapServer()
port = await srv.start()
if port is None:
pytest.skip("zap-mdns/websockets missing")
try: try:
port = await srv.start() assert isinstance(port, int) and port > 0
# Must be the FIRST entry (lowest-priority) when all are free. assert srv.port == port
assert port == free_ports[0]
finally: finally:
await srv.stop() await srv.stop()
async def test_lock_file_created(self, isolated_home, free_ports): async def test_two_servers_get_distinct_ports(self):
srv = zs.ZapServer(ports=free_ports) a = zs.ZapServer()
try: b = zs.ZapServer()
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: try:
port_a = await a.start() port_a = await a.start()
port_b = await b.start() port_b = await b.start()
assert port_a is not None if port_a is None or port_b is None:
assert port_b is not None pytest.skip("zap-mdns/websockets missing")
assert port_a != port_b 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: finally:
await a.stop() await a.stop()
await b.stop() await b.stop()
async def test_stop_releases_lock(self, isolated_home, free_ports): async def test_stop_clears_port(self):
srv = zs.ZapServer(ports=free_ports) srv = zs.ZapServer()
port = await srv.start() port = await srv.start()
assert port is not None if port is None:
pytest.skip("zap-mdns/websockets missing")
await srv.stop() await srv.stop()
# Now another fresh server should be able to claim that same port assert srv.port is None
srv2 = zs.ZapServer(ports=[port])
port2 = await srv2.start()
assert port2 == port
await srv2.stop()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -405,7 +360,6 @@ class TestResolution:
class TestLeases: class TestLeases:
async def test_claim_and_release(self, server, client): async def test_claim_and_release(self, server, client):
srv2_id = "mcp-py-other"
lease = server.claim(client.client_id, ttl=10) lease = server.claim(client.client_id, ttl=10)
assert lease.client_id == client.client_id assert lease.client_id == client.client_id
assert lease.holder == server.server_id assert lease.holder == server.server_id
@@ -447,73 +401,18 @@ class TestLeases:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cluster registry # Cluster discovery (mDNS — best-effort, may be empty in CI)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestClusterRegistry: class TestClusterDiscovery:
async def test_registry_includes_running_server(self, isolated_home, free_ports): async def test_list_mcp_instances_returns_list(self, server):
srv = zs.ZapServer(ports=free_ports, agent_label="dev-agent") # mDNS browse may return [] in containers without multicast routing.
try: # We just assert the call shape is correct.
await srv.start() instances = zs.ZapServer.list_mcp_instances(timeout=0.5)
instances = zs.ZapServer.list_mcp_instances() assert isinstance(instances, list)
mine = [i for i in instances if i["pid"] == os.getpid()] for entry in instances:
assert mine, "this MCP missing from registry" assert "server_id" in entry
assert mine[0]["agent_label"] == "dev-agent" assert "host" in entry
assert mine[0]["port"] == srv.port assert "port" in entry
finally: assert "url" in entry
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()