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 agent 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
|
||||
|
||||
|
||||
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():
|
||||
"""Get FastMCP class lazily."""
|
||||
global _FastMCP
|
||||
@@ -388,12 +430,17 @@ class HanzoMCPServer:
|
||||
pass
|
||||
|
||||
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:
|
||||
result = await self.mcp.call_tool(name, args)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return _normalize_mcp_result(result)
|
||||
|
||||
async def handle_method(method: str, params: Any) -> Any:
|
||||
"""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
|
||||
and call tools directly over the native ZAP binary protocol.
|
||||
This module is a thin pass-through to ``hanzo_tools.browser.zap_server``,
|
||||
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,
|
||||
adding hanzo-mcp-specific request routing on top.
|
||||
There is one and only one ZapServer implementation in the Python stack —
|
||||
this module exists purely so existing callers that import
|
||||
``hanzo_mcp.zap_server`` resolve to the canonical class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from zap.protocol import (
|
||||
HEADER_SIZE,
|
||||
MAX_MESSAGE_SIZE,
|
||||
MSG_HANDSHAKE,
|
||||
MSG_HANDSHAKE_OK,
|
||||
MSG_PING,
|
||||
MSG_PONG,
|
||||
MSG_REQUEST,
|
||||
MSG_RESPONSE,
|
||||
ZAP_MAGIC,
|
||||
ZAP_PORTS,
|
||||
decode,
|
||||
encode,
|
||||
from hanzo_tools.browser.zap_server import (
|
||||
ZapServer,
|
||||
get_or_start_server,
|
||||
get_server,
|
||||
shutdown_server,
|
||||
)
|
||||
|
||||
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(
|
||||
tools: list[dict[str, Any]],
|
||||
call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]],
|
||||
handle_method: Callable[[str, Any], Awaitable[Any]] | None = None,
|
||||
tools,
|
||||
call_tool,
|
||||
handle_method=None,
|
||||
name: str = "hanzo-mcp",
|
||||
preferred_port: int | None = None,
|
||||
) -> ZapServer | None:
|
||||
"""Create and start a ZAP server.
|
||||
preferred_port=None, # noqa: ARG001 — kept for legacy callers, ignored
|
||||
):
|
||||
"""Start (or reuse) the canonical ZAP server and wire MCP routing.
|
||||
|
||||
Args:
|
||||
tools: List of tool dicts with name, description, inputSchema.
|
||||
call_tool: Async callable (name, args) -> result to route tool calls.
|
||||
handle_method: Optional async callable (method, params) -> result for
|
||||
full MCP parity.
|
||||
name: Server name for handshake.
|
||||
preferred_port: Preferred port to try first.
|
||||
tools: List of tool dicts with name/description/inputSchema.
|
||||
call_tool: Async (name, args) -> result for tools/call dispatch.
|
||||
handle_method: Optional async (method, params) -> result for
|
||||
full MCP-method parity beyond tools/list and tools/call.
|
||||
name: Server name used in the handshake.
|
||||
preferred_port: Ignored — kept for API parity. Discovery is
|
||||
mDNS-only; the OS picks the port.
|
||||
|
||||
Returns:
|
||||
ZapServer instance if started, None otherwise.
|
||||
The live ZapServer, or ``None`` if it could not start
|
||||
(websockets / zap-mdns missing).
|
||||
"""
|
||||
server = ZapServer(
|
||||
tools=tools, call_tool=call_tool, handle_method=handle_method, name=name
|
||||
server = await get_or_start_server(agent_label=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 if ok else None
|
||||
return server
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ZapServer",
|
||||
"start_zap_server",
|
||||
"get_or_start_server",
|
||||
"get_server",
|
||||
"shutdown_server",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
# Hanzo AI core SDK (canonical types, config, session, MCP client)
|
||||
"hanzoai>=2.2.0",
|
||||
# ZAP native protocol
|
||||
"zap-protocol>=0.3.0",
|
||||
"zap-protocol>=0.2.1",
|
||||
# Core MCP dependencies
|
||||
"mcp>=1.25.0",
|
||||
"fastmcp>=2.14.4",
|
||||
@@ -34,7 +34,7 @@ dependencies = [
|
||||
"hanzo-tools>=0.3.0",
|
||||
"hanzo-tools-fs>=0.1.0",
|
||||
"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-todo>=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]
|
||||
|
||||
The hanzo-mcp process hosts a ZAP server on the lowest free port in
|
||||
[9999, 9998, 9997, 9996, 9995]. Browser extensions discover and connect to
|
||||
it directly. No node bridge is in the critical path.
|
||||
The hanzo-mcp process hosts a ZAP server on an OS-assigned ephemeral port
|
||||
and advertises it via mDNS under ``_hanzo._tcp.local.`` (HIP-0069).
|
||||
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
|
||||
opt-in fallback for non-ZAP MCP clients. Enable with
|
||||
@@ -34,7 +35,6 @@ from hanzo_tools.browser.browser_tool import (
|
||||
launch_browser_server,
|
||||
)
|
||||
from hanzo_tools.browser.zap_server import (
|
||||
DEFAULT_ZAP_PORTS,
|
||||
ZapClient,
|
||||
ZapServer,
|
||||
get_or_start_server,
|
||||
@@ -80,7 +80,6 @@ __all__ = [
|
||||
# ZAP (canonical, since 0.5.0)
|
||||
"ZapServer",
|
||||
"ZapClient",
|
||||
"DEFAULT_ZAP_PORTS",
|
||||
"get_or_start_server",
|
||||
"get_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.
|
||||
|
||||
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:
|
||||
srv = await get_or_start_server(
|
||||
host=host,
|
||||
ports=ports,
|
||||
agent_label=os.environ.get("HANZO_AGENT_LABEL"),
|
||||
)
|
||||
if _zap_started_event is not None:
|
||||
@@ -145,16 +143,11 @@ def _ensure_zap_server() -> bool:
|
||||
return False
|
||||
|
||||
host = os.environ.get("HANZO_ZAP_HOST", "127.0.0.1")
|
||||
ports_env = os.environ.get("HANZO_ZAP_PORTS")
|
||||
if ports_env:
|
||||
ports = [int(p) for p in ports_env.split(",") if p.strip().isdigit()]
|
||||
else:
|
||||
ports = list(DEFAULT_ZAP_PORTS)
|
||||
|
||||
_zap_started_event = threading.Event()
|
||||
_zap_thread = threading.Thread(
|
||||
target=_run_zap_server,
|
||||
args=(host, ports),
|
||||
args=(host,),
|
||||
daemon=True,
|
||||
name="hanzo-zap-server",
|
||||
)
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
"""ZAP (Zero-latency Agent Protocol) server for hanzo-tools-browser.
|
||||
|
||||
Wire format (compatible with @hanzo/browser-extension/src/shared/zap.ts):
|
||||
[0x5A 0x41 0x50 0x01][type:1][length:4 BE][JSON payload]
|
||||
Wire format and constants come from the canonical ``zap-protocol`` package
|
||||
so every implementation in the stack stays byte-identical.
|
||||
|
||||
Message types:
|
||||
MSG_HANDSHAKE = 0x01 client -> server
|
||||
MSG_HANDSHAKE_OK = 0x02 server -> client
|
||||
MSG_REQUEST = 0x10 bidirectional
|
||||
MSG_RESPONSE = 0x11 bidirectional
|
||||
MSG_PING = 0xFE
|
||||
MSG_PONG = 0xFF
|
||||
Discovery is mDNS-only per HIP-0069: the server binds an OS-assigned
|
||||
ephemeral port and advertises it under ``_hanzo._tcp.local.`` via
|
||||
``zap-mdns``. There is no well-known port pool, no lockfile arbitration,
|
||||
and no shared config registry — clients (browser extensions, sibling
|
||||
MCPs, agents) browse mDNS to find every live ZAP service on the LAN.
|
||||
|
||||
Each hanzo-mcp process runs ONE ZapServer that:
|
||||
1. Claims the lowest free port from [9999, 9998, 9997, 9996, 9995] using a
|
||||
POSIX flock at ~/.hanzo/extension/zap-{port}.lock so multiple MCPs don't
|
||||
collide.
|
||||
2. Accepts WebSocket connections from browser extensions, registers them by
|
||||
{client_id, browser, version, capabilities, ws}.
|
||||
3. Exposes ``send(method, params, browser=None, client_id=None)`` for the
|
||||
browser tool to dispatch RPC calls into a connected extension.
|
||||
4. Persists its presence to ~/.hanzo/extension/config.json:mcp_instances so
|
||||
sibling MCPs can introspect the cluster.
|
||||
One ZapServer per hanzo-mcp process. Lifetime = MCP lifetime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,7 +37,8 @@ from zap.protocol import (
|
||||
MSG_RESPONSE,
|
||||
MSG_PING,
|
||||
MSG_PONG,
|
||||
ZAP_PORTS as DEFAULT_ZAP_PORTS,
|
||||
encode,
|
||||
decode,
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -224,20 +104,21 @@ class ZapServer:
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
ports: Optional[list[int]] = None,
|
||||
agent_label: Optional[str] = None,
|
||||
server_id: Optional[str] = None,
|
||||
request_timeout: float = 30.0,
|
||||
):
|
||||
self.host = host
|
||||
self.ports = list(ports or DEFAULT_ZAP_PORTS)
|
||||
self.agent_label = agent_label or os.environ.get("HANZO_AGENT_LABEL", "")
|
||||
self.server_id = server_id or f"mcp-py-{os.getpid()}"
|
||||
# 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._port: Optional[int] = None
|
||||
self._lock_fd: Optional[int] = None
|
||||
self._server: Any = None # websockets server
|
||||
self._mdns_handle: Any = None
|
||||
self._clients: dict[str, ZapClient] = {}
|
||||
self._ws_to_id: dict[Any, str] = {}
|
||||
self._pending: dict[str, asyncio.Future] = {}
|
||||
@@ -258,89 +139,77 @@ class ZapServer:
|
||||
# ---- lifecycle ------------------------------------------------------
|
||||
|
||||
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:
|
||||
import websockets # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning("websockets not installed; ZAP server disabled")
|
||||
return None
|
||||
|
||||
for port in self.ports:
|
||||
fd = _claim_port_lock(port)
|
||||
if fd is None:
|
||||
logger.debug("zap port %d already locked by another mcp", port)
|
||||
continue
|
||||
if not _port_is_free(port, self.host):
|
||||
_release_port_lock(fd)
|
||||
logger.debug("zap port %d in use (no lock but socket bound)", port)
|
||||
continue
|
||||
try:
|
||||
from websockets.asyncio.server import serve as _serve
|
||||
|
||||
async def _handler(websocket):
|
||||
await self._handle_connection(websocket)
|
||||
|
||||
self._server = await _serve(_handler, self.host, port)
|
||||
except ImportError:
|
||||
# Older websockets API (< 13)
|
||||
from websockets.server import serve as _serve # type: ignore
|
||||
|
||||
async def _handler_legacy(websocket, _path):
|
||||
await self._handle_connection(websocket)
|
||||
|
||||
self._server = await _serve(_handler_legacy, self.host, port) # type: ignore
|
||||
except OSError as e:
|
||||
_release_port_lock(fd)
|
||||
logger.debug("zap bind on %d failed: %s", port, e)
|
||||
continue
|
||||
|
||||
self._port = port
|
||||
self._lock_fd = fd
|
||||
self._register_in_config()
|
||||
logger.info(
|
||||
"ZAP server listening on ws://%s:%d (mcp=%s, agent=%s)",
|
||||
self.host,
|
||||
port,
|
||||
self.server_id,
|
||||
self.agent_label or "?",
|
||||
try:
|
||||
import zap_mdns
|
||||
except ImportError:
|
||||
logger.error(
|
||||
"zap-mdns not installed; the server is unreachable without it. "
|
||||
"`pip install zap-mdns`."
|
||||
)
|
||||
# Best-effort mDNS publish so LAN-wide consumers can find us
|
||||
# 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
|
||||
return None
|
||||
|
||||
logger.warning("ZAP: no free port in %s", self.ports)
|
||||
return None
|
||||
from websockets.asyncio.server import serve as _serve
|
||||
|
||||
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:
|
||||
"""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.
|
||||
try:
|
||||
if getattr(self, "_mdns_handle", None) is not None:
|
||||
if self._mdns_handle is not None:
|
||||
try:
|
||||
self._mdns_handle.close()
|
||||
self._mdns_handle = None
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
self._mdns_handle = None
|
||||
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
try:
|
||||
@@ -356,10 +225,6 @@ class ZapServer:
|
||||
pass
|
||||
self._clients.clear()
|
||||
self._ws_to_id.clear()
|
||||
|
||||
self._unregister_from_config()
|
||||
_release_port_lock(self._lock_fd)
|
||||
self._lock_fd = None
|
||||
self._port = None
|
||||
|
||||
# ---- public API -----------------------------------------------------
|
||||
@@ -493,60 +358,29 @@ class ZapServer:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---- registry (cross-MCP visibility) -------------------------------
|
||||
|
||||
def _register_in_config(self) -> None:
|
||||
def mut(cfg: dict) -> None:
|
||||
instances = cfg.setdefault("mcp_instances", [])
|
||||
# purge stale (dead pids) and any prior entry for our own
|
||||
# (pid, port). In production each MCP is its own process so
|
||||
# de-dup by pid alone would suffice; we key on (pid, port) so
|
||||
# tests and edge cases that bring up two ZAP servers under one
|
||||
# pid both stay visible.
|
||||
instances[:] = [
|
||||
i
|
||||
for i in instances
|
||||
if _pid_alive(i.get("pid"))
|
||||
and not (i.get("pid") == os.getpid() and i.get("port") == self._port)
|
||||
]
|
||||
instances.append(
|
||||
{
|
||||
"port": self._port,
|
||||
"pid": os.getpid(),
|
||||
"started_at": time.time(),
|
||||
"agent_label": self.agent_label,
|
||||
"server_id": self.server_id,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
_atomic_update_config(mut)
|
||||
except Exception as e:
|
||||
logger.warning("config registry write failed: %s", e)
|
||||
|
||||
def _unregister_from_config(self) -> None:
|
||||
target_pid = os.getpid()
|
||||
target_port = self._port
|
||||
|
||||
def mut(cfg: dict) -> None:
|
||||
instances = cfg.get("mcp_instances", [])
|
||||
cfg["mcp_instances"] = [
|
||||
i
|
||||
for i in instances
|
||||
if not (i.get("pid") == target_pid and i.get("port") == target_port)
|
||||
]
|
||||
|
||||
try:
|
||||
_atomic_update_config(mut)
|
||||
except Exception:
|
||||
pass
|
||||
# ---- cluster discovery ---------------------------------------------
|
||||
# Cross-MCP visibility comes from mDNS, not a shared file. Use
|
||||
# ``zap_mdns.browse()`` to enumerate live MCPs on the LAN.
|
||||
|
||||
@staticmethod
|
||||
def list_mcp_instances() -> list[dict]:
|
||||
"""Read the cluster registry (filters dead pids)."""
|
||||
cfg = _read_config()
|
||||
instances = cfg.get("mcp_instances", [])
|
||||
return [i for i in instances if _pid_alive(i.get("pid"))]
|
||||
def list_mcp_instances(timeout: float = 1.5) -> list[dict]:
|
||||
"""Browse ``_hanzo._tcp.local.`` for every live ZAP service."""
|
||||
try:
|
||||
import zap_mdns
|
||||
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 -----------------------------------------------------
|
||||
|
||||
@@ -669,22 +503,6 @@ class ZapServer:
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -697,18 +515,17 @@ _singleton_lock = asyncio.Lock()
|
||||
async def get_or_start_server(
|
||||
*,
|
||||
host: str = "127.0.0.1",
|
||||
ports: Optional[list[int]] = None,
|
||||
agent_label: Optional[str] = None,
|
||||
) -> Optional[ZapServer]:
|
||||
"""Return the process-wide ZAP server, starting it if needed.
|
||||
|
||||
Returns ``None`` if no port could be bound (other MCPs hold all 5).
|
||||
Returns ``None`` if either ``websockets`` or ``zap-mdns`` is missing.
|
||||
"""
|
||||
global _singleton
|
||||
async with _singleton_lock:
|
||||
if _singleton is not None and _singleton.port is not None:
|
||||
return _singleton
|
||||
srv = ZapServer(host=host, ports=ports, agent_label=agent_label)
|
||||
srv = ZapServer(host=host, agent_label=agent_label)
|
||||
port = await srv.start()
|
||||
if port is None:
|
||||
return None
|
||||
@@ -733,7 +550,6 @@ __all__ = [
|
||||
"ZapClient",
|
||||
"ZapServer",
|
||||
"BrowserLease",
|
||||
"DEFAULT_ZAP_PORTS",
|
||||
"DEFAULT_BROWSER_PREFERENCE",
|
||||
"DEFAULT_LEASE_TTL",
|
||||
"MSG_HANDSHAKE",
|
||||
|
||||
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-browser"
|
||||
version = "0.5.2"
|
||||
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."
|
||||
version = "0.5.4"
|
||||
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"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -2,21 +2,18 @@
|
||||
|
||||
Covers:
|
||||
- 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.
|
||||
- Browser resolution priority (client_id > browser > default preference).
|
||||
- Browser leases (claim / release / reject when held by other holder).
|
||||
- Cluster registry in ~/.hanzo/extension/config.json.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import websockets
|
||||
@@ -43,15 +40,14 @@ class TestWireFormat:
|
||||
assert zs.MSG_PING == 0xFE
|
||||
assert zs.MSG_PONG == 0xFF
|
||||
|
||||
def test_default_ports(self):
|
||||
assert zs.DEFAULT_ZAP_PORTS == [9999, 9998, 9997, 9996, 9995]
|
||||
|
||||
def test_encode_layout(self):
|
||||
from zap.protocol import HEADER_SIZE
|
||||
|
||||
frame = zs.encode(zs.MSG_REQUEST, {"id": "x", "method": "y"})
|
||||
assert frame[:4] == zs.ZAP_MAGIC
|
||||
assert frame[4] == zs.MSG_REQUEST
|
||||
(length,) = struct.unpack(">I", frame[5:9])
|
||||
assert length == len(frame) - zs.HEADER_SIZE
|
||||
assert length == len(frame) - HEADER_SIZE
|
||||
assert json.loads(frame[9:].decode()) == {"id": "x", "method": "y"}
|
||||
|
||||
def test_round_trip(self):
|
||||
@@ -168,42 +164,14 @@ class MockExtensionClient:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Sandbox ~/.hanzo so tests never collide with real user state."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
yield tmp_path
|
||||
|
||||
|
||||
def _free_ports(n: int) -> list[int]:
|
||||
"""Return ``n`` distinct, currently-unbound TCP ports."""
|
||||
import socket as _s
|
||||
|
||||
ports: list[int] = []
|
||||
socks: list[_s.socket] = []
|
||||
try:
|
||||
for _ in range(n):
|
||||
s = _s.socket(_s.AF_INET, _s.SOCK_STREAM)
|
||||
s.bind(("127.0.0.1", 0))
|
||||
socks.append(s)
|
||||
ports.append(s.getsockname()[1])
|
||||
finally:
|
||||
for s in socks:
|
||||
s.close()
|
||||
return ports
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def free_ports():
|
||||
"""5 ephemeral, currently-free ports — replaces the well-known
|
||||
9999..9995 range so tests don't depend on machine state."""
|
||||
return _free_ports(5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def server(isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
async def server():
|
||||
"""A live ZapServer on an OS-assigned port. mDNS publish is best-effort
|
||||
— if zap-mdns is unavailable the start() returns None, and the test
|
||||
is skipped (CI without zeroconf shouldn't hard-fail)."""
|
||||
srv = zs.ZapServer()
|
||||
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
|
||||
await srv.stop()
|
||||
|
||||
@@ -224,50 +192,37 @@ async def client(server):
|
||||
|
||||
|
||||
class TestServerLifecycle:
|
||||
async def test_binds_on_first_free_port(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
async def test_binds_on_ephemeral_port(self):
|
||||
srv = zs.ZapServer()
|
||||
port = await srv.start()
|
||||
if port is None:
|
||||
pytest.skip("zap-mdns/websockets missing")
|
||||
try:
|
||||
port = await srv.start()
|
||||
# Must be the FIRST entry (lowest-priority) when all are free.
|
||||
assert port == free_ports[0]
|
||||
assert isinstance(port, int) and port > 0
|
||||
assert srv.port == port
|
||||
finally:
|
||||
await srv.stop()
|
||||
|
||||
async def test_lock_file_created(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
try:
|
||||
port = await srv.start()
|
||||
assert port is not None
|
||||
lock_path = Path(isolated_home) / ".hanzo" / "extension" / f"zap-{port}.lock"
|
||||
assert lock_path.exists()
|
||||
finally:
|
||||
await srv.stop()
|
||||
|
||||
async def test_two_servers_pick_different_ports(self, isolated_home, free_ports):
|
||||
a = zs.ZapServer(ports=free_ports)
|
||||
b = zs.ZapServer(ports=free_ports)
|
||||
async def test_two_servers_get_distinct_ports(self):
|
||||
a = zs.ZapServer()
|
||||
b = zs.ZapServer()
|
||||
try:
|
||||
port_a = await a.start()
|
||||
port_b = await b.start()
|
||||
assert port_a is not None
|
||||
assert port_b is not None
|
||||
if port_a is None or port_b is None:
|
||||
pytest.skip("zap-mdns/websockets missing")
|
||||
assert port_a != port_b
|
||||
# b should pick a strictly LOWER-priority slot than a
|
||||
assert free_ports.index(port_b) > free_ports.index(port_a)
|
||||
finally:
|
||||
await a.stop()
|
||||
await b.stop()
|
||||
|
||||
async def test_stop_releases_lock(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
async def test_stop_clears_port(self):
|
||||
srv = zs.ZapServer()
|
||||
port = await srv.start()
|
||||
assert port is not None
|
||||
if port is None:
|
||||
pytest.skip("zap-mdns/websockets missing")
|
||||
await srv.stop()
|
||||
# Now another fresh server should be able to claim that same port
|
||||
srv2 = zs.ZapServer(ports=[port])
|
||||
port2 = await srv2.start()
|
||||
assert port2 == port
|
||||
await srv2.stop()
|
||||
assert srv.port is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -405,7 +360,6 @@ class TestResolution:
|
||||
|
||||
class TestLeases:
|
||||
async def test_claim_and_release(self, server, client):
|
||||
srv2_id = "mcp-py-other"
|
||||
lease = server.claim(client.client_id, ttl=10)
|
||||
assert lease.client_id == client.client_id
|
||||
assert lease.holder == server.server_id
|
||||
@@ -447,73 +401,18 @@ class TestLeases:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster registry
|
||||
# Cluster discovery (mDNS — best-effort, may be empty in CI)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClusterRegistry:
|
||||
async def test_registry_includes_running_server(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports, agent_label="dev-agent")
|
||||
try:
|
||||
await srv.start()
|
||||
instances = zs.ZapServer.list_mcp_instances()
|
||||
mine = [i for i in instances if i["pid"] == os.getpid()]
|
||||
assert mine, "this MCP missing from registry"
|
||||
assert mine[0]["agent_label"] == "dev-agent"
|
||||
assert mine[0]["port"] == srv.port
|
||||
finally:
|
||||
await srv.stop()
|
||||
|
||||
async def test_unregister_on_stop(self, isolated_home, free_ports):
|
||||
srv = zs.ZapServer(ports=free_ports)
|
||||
port = await srv.start()
|
||||
await srv.stop()
|
||||
instances = zs.ZapServer.list_mcp_instances()
|
||||
# The (pid, port) tuple this server occupied must be gone.
|
||||
assert not any(
|
||||
i.get("pid") == os.getpid() and i.get("port") == port for i in instances
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-MCP scenario (the headline use case)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiMcp:
|
||||
async def test_two_mcps_with_one_extension(self, isolated_home, free_ports):
|
||||
"""Two Python ZAP servers running side by side; one mock extension
|
||||
connects to BOTH; each MCP can dispatch independently."""
|
||||
srv_a = zs.ZapServer(ports=free_ports, agent_label="agent-A")
|
||||
srv_b = zs.ZapServer(ports=free_ports, agent_label="agent-B")
|
||||
try:
|
||||
port_a = await srv_a.start()
|
||||
port_b = await srv_b.start()
|
||||
assert port_a is not None and port_b is not None
|
||||
assert port_a != port_b
|
||||
|
||||
client_a = MockExtensionClient(port_a, browser="firefox", client_id="ext-shared")
|
||||
client_b = MockExtensionClient(port_b, browser="firefox", client_id="ext-shared")
|
||||
await client_a.connect()
|
||||
await client_b.connect()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
client_a.response_handler = lambda m, p: {"served_by": "A"}
|
||||
client_b.response_handler = lambda m, p: {"served_by": "B"}
|
||||
|
||||
# Dispatch from each MCP — must NOT cross-talk.
|
||||
r_a = await srv_a.send("any", {})
|
||||
r_b = await srv_b.send("any", {})
|
||||
assert r_a == {"served_by": "A"}
|
||||
assert r_b == {"served_by": "B"}
|
||||
|
||||
# Registry sees both
|
||||
instances = zs.ZapServer.list_mcp_instances()
|
||||
labels = {i.get("agent_label") for i in instances}
|
||||
assert {"agent-A", "agent-B"}.issubset(labels)
|
||||
|
||||
await client_a.close()
|
||||
await client_b.close()
|
||||
finally:
|
||||
await srv_a.stop()
|
||||
await srv_b.stop()
|
||||
class TestClusterDiscovery:
|
||||
async def test_list_mcp_instances_returns_list(self, server):
|
||||
# mDNS browse may return [] in containers without multicast routing.
|
||||
# We just assert the call shape is correct.
|
||||
instances = zs.ZapServer.list_mcp_instances(timeout=0.5)
|
||||
assert isinstance(instances, list)
|
||||
for entry in instances:
|
||||
assert "server_id" in entry
|
||||
assert "host" in entry
|
||||
assert "port" in entry
|
||||
assert "url" in entry
|
||||
|
||||
Reference in New Issue
Block a user