the browser transport is the zapd consumer, and only that

hanzo_mcp.zap_server imported hanzo_tools.browser.zap_server, which does not
exist — the module could not be imported at all. Its caller swallowed that in
`except ImportError: return`, so _start_zap_server() ran on every `run()` and
started nothing, and the HANZO_NO_ZAP switch guarded a path that never had an
effect either way.

The browser tool reaches Chrome through the native host -> zapd -> the socket
at ~/.zap/run/zapd.sock, where hanzo-mcp is a consumer (hanzo_tools.browser
.zapd_consumer). That is the transport, so the WebSocket server and its mDNS
discovery go, along with _normalize_mcp_result, which existed to shape results
for that wire and had no other caller.
This commit is contained in:
zooqueen
2026-08-18 23:13:53 -07:00
parent 22c0e467a3
commit 95cf0799f9
3 changed files with 1 additions and 294 deletions
-206
View File
@@ -34,48 +34,6 @@ _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
@@ -386,167 +344,6 @@ class HanzoMCPServer:
# Ignore cleanup errors during shutdown
pass
def _start_zap_server(self) -> None:
"""Start ZAP server in a background thread for browser extension discovery."""
if os.environ.get("HANZO_NO_ZAP"):
return
try:
from hanzo_mcp.zap_server import ZapServer
except ImportError:
return
# Collect tool manifest from registered MCP tools
tool_list: list[dict[str, Any]] = []
try:
# FastMCP _tool_manager._tools is a dict[str, Tool]
tm = getattr(self.mcp, "_tool_manager", None)
if tm is not None:
tools_dict = getattr(tm, "_tools", {})
for name, tool in tools_dict.items():
tool_list.append(
{
"name": name,
"description": getattr(tool, "description", ""),
"inputSchema": getattr(tool, "parameters", {}),
}
)
except Exception:
pass
if not tool_list:
# Fallback: try listing via the tool registry
try:
tools = self.mcp.list_tools()
for t in tools:
tool_list.append(
{
"name": t.name,
"description": t.description or "",
"inputSchema": getattr(t, "parameters", {}),
}
)
except Exception:
pass
async def call_tool(name: str, args: dict) -> Any: # type: ignore[type-arg]
"""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)
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.
Routes resources/*, prompts/*, and any other MCP method to the
underlying FastMCP server, so ZAP clients get the same capabilities
as stdio/SSE MCP clients.
"""
# resources/list
if method == "resources/list":
try:
resources = self.mcp.list_resources()
return {
"resources": [
{
"uri": r.uri,
"name": r.name,
"mimeType": getattr(r, "mimeType", "text/plain"),
}
for r in resources
]
}
except Exception:
return {"resources": []}
# resources/read
if method == "resources/read":
uri = (params or {}).get("uri", "")
try:
content = await self.mcp.read_resource(uri)
return {
"contents": [
{"uri": uri, "mimeType": "text/plain", "text": str(content)}
]
}
except Exception as e:
return {
"contents": [
{
"uri": uri,
"mimeType": "text/plain",
"text": f"Error: {e}",
}
]
}
# prompts/list
if method == "prompts/list":
try:
prompts = self.mcp.list_prompts()
return {
"prompts": [
{
"name": p.name,
"description": getattr(p, "description", ""),
}
for p in prompts
]
}
except Exception:
return {"prompts": []}
# prompts/get
if method == "prompts/get":
name = (params or {}).get("name", "")
try:
prompt = await self.mcp.get_prompt(
name, params.get("arguments", {})
)
return prompt
except Exception as e:
raise ValueError(f"Prompt error: {e}")
raise ValueError(f"Unsupported method: {method}")
def _run_zap_loop():
import asyncio as _asyncio
loop = _asyncio.new_event_loop()
_asyncio.set_event_loop(loop)
try:
from hanzo_mcp.zap_server import start_zap_server
server = loop.run_until_complete(
start_zap_server(
tools=tool_list,
call_tool=call_tool,
handle_method=handle_method,
name=(
self.mcp.name if hasattr(self.mcp, "name") else "hanzo-mcp"
),
)
)
if server:
self._zap_server = server
loop.run_forever()
except Exception as e:
log = logging.getLogger(__name__)
log.debug(f"[ZAP] Failed to start: {e}")
finally:
loop.close()
self._zap_server = None
zap_thread = threading.Thread(target=_run_zap_loop, daemon=True)
zap_thread.start()
def run(self, transport: str = "stdio", allowed_paths: list[str] | None = None):
"""Run the MCP server.
@@ -578,9 +375,6 @@ class HanzoMCPServer:
# Set up cleanup handlers before running
self._setup_cleanup_handlers()
# Start ZAP server for browser extension discovery (background thread)
self._start_zap_server()
# Run the server
transport_type = cast(Literal["stdio", "sse"], transport)
self.mcp.run(transport=transport_type)
-87
View File
@@ -1,87 +0,0 @@
"""ZAP server adaptor for hanzo-mcp.
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.
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 logging
from hanzo_tools.browser.zap_server import (
ZapServer,
get_or_start_server,
get_server,
shutdown_server,
)
logger = logging.getLogger(__name__)
async def start_zap_server(
tools,
call_tool,
handle_method=None,
name: str = "hanzo-mcp",
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 (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:
The live ZapServer, or ``None`` if it could not start
(websockets / zap-mdns missing).
"""
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))
)
return server
__all__ = [
"ZapServer",
"start_zap_server",
"get_or_start_server",
"get_server",
"shutdown_server",
]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-mcp"
version = "0.15.14"
version = "0.15.15"
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"