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