Skip to content

Commit 8d73eaf

Browse files
committed
feat(zap): add ZAP WebSocket server for browser extension discovery
Binary protocol server matching Node MCP implementation. Auto-starts in background thread on run(). Supports multi-browser connections on ports 9999-9995.
1 parent 5936909 commit 8d73eaf

File tree

3 files changed

+356
-1
lines changed

3 files changed

+356
-1
lines changed

pkg/hanzo-mcp/hanzo_mcp/server.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,78 @@ def _cleanup_sessions(self) -> None:
332332
# Ignore cleanup errors during shutdown
333333
pass
334334

335+
def _start_zap_server(self) -> None:
336+
"""Start ZAP server in a background thread for browser extension discovery."""
337+
if os.environ.get("HANZO_NO_ZAP"):
338+
return
339+
340+
try:
341+
from hanzo_mcp.zap_server import ZapServer
342+
except ImportError:
343+
return
344+
345+
# Collect tool manifest from registered MCP tools
346+
tool_list: list[dict] = []
347+
try:
348+
# FastMCP stores tools internally — extract names and schemas
349+
for name, tool in getattr(self.mcp, "_tool_manager", {}).items():
350+
tool_list.append({
351+
"name": name,
352+
"description": getattr(tool, "description", ""),
353+
"inputSchema": getattr(tool, "parameters", {}),
354+
})
355+
except Exception:
356+
pass
357+
358+
if not tool_list:
359+
# Fallback: try listing via the tool registry
360+
try:
361+
tools = self.mcp.list_tools()
362+
for t in tools:
363+
tool_list.append({
364+
"name": t.name,
365+
"description": t.description or "",
366+
"inputSchema": getattr(t, "inputSchema", {}),
367+
})
368+
except Exception:
369+
pass
370+
371+
async def call_tool(name: str, args: dict) -> any:
372+
"""Route ZAP tool calls to the MCP server."""
373+
try:
374+
result = await self.mcp.call_tool(name, args)
375+
return result
376+
except Exception as e:
377+
return {"error": str(e)}
378+
379+
def _run_zap_loop():
380+
import asyncio as _asyncio
381+
382+
loop = _asyncio.new_event_loop()
383+
_asyncio.set_event_loop(loop)
384+
try:
385+
from hanzo_mcp.zap_server import start_zap_server
386+
387+
server = loop.run_until_complete(
388+
start_zap_server(
389+
tools=tool_list,
390+
call_tool=call_tool,
391+
name=self.mcp.name if hasattr(self.mcp, "name") else "hanzo-mcp",
392+
)
393+
)
394+
if server:
395+
self._zap_server = server
396+
loop.run_forever()
397+
except Exception as e:
398+
log = logging.getLogger(__name__)
399+
log.debug(f"[ZAP] Failed to start: {e}")
400+
finally:
401+
loop.close()
402+
403+
self._zap_server = None
404+
zap_thread = threading.Thread(target=_run_zap_loop, daemon=True)
405+
zap_thread.start()
406+
335407
def run(self, transport: str = "stdio", allowed_paths: list[str] | None = None):
336408
"""Run the MCP server.
337409
@@ -363,6 +435,9 @@ def run(self, transport: str = "stdio", allowed_paths: list[str] | None = None):
363435
# Set up cleanup handlers before running
364436
self._setup_cleanup_handlers()
365437

438+
# Start ZAP server for browser extension discovery (background thread)
439+
self._start_zap_server()
440+
366441
# Run the server
367442
transport_type = cast(Literal["stdio", "sse"], transport)
368443
self.mcp.run(transport=transport_type)
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""ZAP (Zero-latency Agent Protocol) Server for hanzo-mcp.
2+
3+
Allows Hanzo browser extensions to discover this MCP server
4+
and call tools directly over a binary WebSocket protocol.
5+
6+
Protocol: 9-byte header + JSON payload
7+
[0x5A 0x41 0x50 0x01] magic "ZAP\x01"
8+
[type] 1 byte message type
9+
[length] 4 bytes big-endian payload length
10+
[payload] UTF-8 JSON
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import asyncio
16+
import json
17+
import logging
18+
import struct
19+
import time
20+
from typing import Any, Callable, Awaitable
21+
22+
logger = logging.getLogger(__name__)
23+
24+
# ── Protocol Constants ──────────────────────────────────────────────────
25+
ZAP_MAGIC = b"\x5a\x41\x50\x01" # "ZAP\x01"
26+
MSG_HANDSHAKE = 0x01
27+
MSG_HANDSHAKE_OK = 0x02
28+
MSG_REQUEST = 0x10
29+
MSG_RESPONSE = 0x11
30+
MSG_PING = 0xFE
31+
MSG_PONG = 0xFF
32+
33+
ZAP_PORTS = [9999, 9998, 9997, 9996, 9995]
34+
HEADER_SIZE = 9 # 4 (magic) + 1 (type) + 4 (length)
35+
36+
37+
# ── Encode / Decode ─────────────────────────────────────────────────────
38+
39+
def encode(msg_type: int, payload: Any) -> bytes:
40+
"""Encode a ZAP message: magic + type + length + JSON payload."""
41+
data = json.dumps(payload).encode("utf-8")
42+
header = ZAP_MAGIC + struct.pack("!BL", msg_type, len(data))
43+
return header + data
44+
45+
46+
def decode(buf: bytes) -> tuple[int, Any] | None:
47+
"""Decode a ZAP message. Returns (type, payload) or None."""
48+
if len(buf) < HEADER_SIZE:
49+
return None
50+
51+
if buf[:4] != ZAP_MAGIC:
52+
# Not ZAP binary — try plain JSON fallback
53+
try:
54+
payload = json.loads(buf.decode("utf-8"))
55+
return (MSG_REQUEST, payload)
56+
except (json.JSONDecodeError, UnicodeDecodeError):
57+
return None
58+
59+
msg_type = buf[4]
60+
length = struct.unpack("!L", buf[5:9])[0]
61+
if len(buf) < HEADER_SIZE + length:
62+
return None
63+
64+
try:
65+
payload = json.loads(buf[HEADER_SIZE : HEADER_SIZE + length].decode("utf-8"))
66+
return (msg_type, payload)
67+
except (json.JSONDecodeError, UnicodeDecodeError):
68+
return None
69+
70+
71+
# ── Client tracking ─────────────────────────────────────────────────────
72+
73+
class ZapClient:
74+
__slots__ = ("writer", "client_id", "browser", "version", "connected_at")
75+
76+
def __init__(
77+
self,
78+
writer: asyncio.StreamWriter,
79+
client_id: str = "unknown",
80+
browser: str = "unknown",
81+
version: str = "0",
82+
):
83+
self.writer = writer
84+
self.client_id = client_id
85+
self.browser = browser
86+
self.version = version
87+
self.connected_at = time.time()
88+
89+
90+
# ── WebSocket-like framing over raw TCP ─────────────────────────────────
91+
# We use asyncio websockets for the actual WS server.
92+
93+
94+
class ZapServer:
95+
"""ZAP WebSocket server for browser extension discovery."""
96+
97+
def __init__(
98+
self,
99+
tools: list[dict[str, Any]],
100+
call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]],
101+
name: str = "hanzo-mcp",
102+
):
103+
self.tools = tools
104+
self.call_tool = call_tool
105+
self.name = name
106+
self.server_id = f"mcp-{int(time.time()):x}"
107+
self.clients: dict[Any, ZapClient] = {}
108+
self._server: Any = None
109+
self.port: int | None = None
110+
111+
self.tool_manifest = [
112+
{
113+
"name": t.get("name", ""),
114+
"description": t.get("description", ""),
115+
"inputSchema": t.get("inputSchema", t.get("input_schema", {})),
116+
}
117+
for t in self.tools
118+
]
119+
120+
async def _handle_connection(self, websocket: Any) -> None:
121+
"""Handle a single WebSocket connection."""
122+
client: ZapClient | None = None
123+
try:
124+
async for raw in websocket:
125+
if isinstance(raw, str):
126+
raw = raw.encode("utf-8")
127+
128+
result = decode(raw)
129+
if result is None:
130+
continue
131+
132+
msg_type, payload = result
133+
134+
if msg_type == MSG_HANDSHAKE:
135+
client_id = payload.get("clientId", "unknown")
136+
browser = payload.get("browser", "unknown")
137+
version = payload.get("version", "0")
138+
client = ZapClient(
139+
writer=websocket,
140+
client_id=client_id,
141+
browser=browser,
142+
version=version,
143+
)
144+
self.clients[websocket] = client
145+
logger.info(
146+
f"[ZAP] Client connected: {client_id} ({browser} v{version})"
147+
)
148+
await websocket.send(
149+
encode(
150+
MSG_HANDSHAKE_OK,
151+
{
152+
"serverId": self.server_id,
153+
"name": self.name,
154+
"tools": self.tool_manifest,
155+
},
156+
)
157+
)
158+
159+
elif msg_type == MSG_REQUEST:
160+
req_id = payload.get("id", "")
161+
method = payload.get("method", "")
162+
params = payload.get("params", {})
163+
await self._handle_request(websocket, req_id, method, params)
164+
165+
elif msg_type == MSG_PING:
166+
await websocket.send(encode(MSG_PONG, {}))
167+
168+
except Exception as e:
169+
logger.debug(f"[ZAP] Connection error: {e}")
170+
finally:
171+
if websocket in self.clients:
172+
client = self.clients.pop(websocket)
173+
logger.info(f"[ZAP] Client disconnected: {client.client_id}")
174+
175+
async def _handle_request(
176+
self, websocket: Any, req_id: str, method: str, params: Any
177+
) -> None:
178+
"""Handle a ZAP RPC request."""
179+
try:
180+
if method == "tools/list":
181+
result = {"tools": self.tool_manifest}
182+
183+
elif method == "tools/call":
184+
tool_name = (params or {}).get("name", "")
185+
args = (params or {}).get("arguments", {})
186+
if not tool_name:
187+
raise ValueError("Missing tool name")
188+
result = await self.call_tool(tool_name, args)
189+
190+
elif method == "notifications/elementSelected":
191+
result = {"acknowledged": True}
192+
193+
else:
194+
raise ValueError(f"Unknown method: {method}")
195+
196+
await websocket.send(encode(MSG_RESPONSE, {"id": req_id, "result": result}))
197+
198+
except Exception as e:
199+
await websocket.send(
200+
encode(
201+
MSG_RESPONSE,
202+
{
203+
"id": req_id,
204+
"error": {"code": -1, "message": str(e)},
205+
},
206+
)
207+
)
208+
209+
async def start(self, preferred_port: int | None = None) -> bool:
210+
"""Start the ZAP server on the first available port."""
211+
try:
212+
import websockets
213+
except ImportError:
214+
logger.warning(
215+
"[ZAP] websockets package not installed. "
216+
"Install with: pip install websockets"
217+
)
218+
return False
219+
220+
ports = (
221+
[preferred_port, *[p for p in ZAP_PORTS if p != preferred_port]]
222+
if preferred_port
223+
else ZAP_PORTS
224+
)
225+
226+
for port in ports:
227+
try:
228+
self._server = await websockets.serve(
229+
self._handle_connection,
230+
"127.0.0.1",
231+
port,
232+
)
233+
self.port = port
234+
logger.info(
235+
f"[ZAP] Server listening on ws://127.0.0.1:{port} "
236+
f"({len(self.tool_manifest)} tools)"
237+
)
238+
return True
239+
except OSError:
240+
continue
241+
242+
logger.warning(
243+
"[ZAP] Could not bind to any port (9999-9995). ZAP discovery disabled."
244+
)
245+
return False
246+
247+
def stop(self) -> None:
248+
"""Stop the ZAP server."""
249+
if self._server:
250+
self._server.close()
251+
self._server = None
252+
self.port = None
253+
for ws in list(self.clients.keys()):
254+
try:
255+
asyncio.ensure_future(ws.close())
256+
except Exception:
257+
pass
258+
self.clients.clear()
259+
260+
261+
async def start_zap_server(
262+
tools: list[dict[str, Any]],
263+
call_tool: Callable[[str, dict[str, Any]], Awaitable[Any]],
264+
name: str = "hanzo-mcp",
265+
preferred_port: int | None = None,
266+
) -> ZapServer | None:
267+
"""Create and start a ZAP server.
268+
269+
Args:
270+
tools: List of tool dicts with name, description, inputSchema.
271+
call_tool: Async callable (name, args) -> result to route tool calls.
272+
name: Server name for handshake.
273+
preferred_port: Preferred port to try first.
274+
275+
Returns:
276+
ZapServer instance if started, None otherwise.
277+
"""
278+
server = ZapServer(tools=tools, call_tool=call_tool, name=name)
279+
ok = await server.start(preferred_port=preferred_port)
280+
return server if ok else None

pkg/hanzo-mcp/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "hanzo-mcp"
7-
version = "0.13.0"
7+
version = "0.13.1"
88
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
99
readme = "README.md"
1010
requires-python = ">=3.12"

0 commit comments

Comments
 (0)