mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user