mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(browser): add CDP bridge server for extension integration
- Add cdp_bridge_server.py with WebSocket bridge - CDPBridgeServer routes commands to browser extension - CDPBridgeClient for programmatic browser control - Export bridge classes from browser package - Update vscode.md with extension integration docs
This commit is contained in:
@@ -169,6 +169,83 @@ hanzo-mcp 2>/tmp/hanzo-mcp.log
|
||||
3. **Disable unused tools** - Reduces memory usage
|
||||
4. **Use project config** - Faster tool discovery
|
||||
|
||||
## Browser Extension Integration
|
||||
|
||||
The Hanzo Browser Extension enables AI control of browser tabs through the browser tool.
|
||||
|
||||
### Installation
|
||||
|
||||
1. Install the browser extension from the Chrome/Firefox store (or build from source)
|
||||
2. Start the CDP bridge server:
|
||||
|
||||
```bash
|
||||
python -m hanzo_tools.browser.cdp_bridge_server
|
||||
# Runs on ws://localhost:9223 by default
|
||||
```
|
||||
|
||||
3. The extension automatically connects to the bridge
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
|
||||
│ hanzo-mcp │────▶│ CDP Bridge │────▶│ Browser Extension │
|
||||
│ browser tool │◀────│ Server (9223) │◀────│ (CDP Provider) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────────┘
|
||||
▲
|
||||
│ WebSocket
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ Chrome │
|
||||
│ Tabs │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HANZO_CDP_BRIDGE_HOST` | `localhost` | Bridge server host |
|
||||
| `HANZO_CDP_BRIDGE_PORT` | `9223` | Bridge server port |
|
||||
|
||||
### Usage with MCP
|
||||
|
||||
Once connected, the browser tool can control tabs through the extension:
|
||||
|
||||
```python
|
||||
# In your AI/MCP context
|
||||
browser(action="navigate", url="https://example.com")
|
||||
browser(action="click", selector="#login-button")
|
||||
browser(action="fill", selector="#email", text="[email protected]")
|
||||
browser(action="screenshot")
|
||||
```
|
||||
|
||||
### Programmatic Usage
|
||||
|
||||
```python
|
||||
from hanzo_tools.browser import CDPBridgeClient
|
||||
|
||||
# Connect to the bridge
|
||||
client = CDPBridgeClient(port=9223)
|
||||
await client.connect()
|
||||
|
||||
# Control browser
|
||||
await client.navigate("https://example.com")
|
||||
await client.click("#submit")
|
||||
screenshot = await client.screenshot(full_page=True)
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
If the extension isn't connecting:
|
||||
|
||||
1. Check extension is installed and enabled
|
||||
2. Verify bridge server is running: `curl http://localhost:9223/health`
|
||||
3. Check browser console for errors (F12 → Console)
|
||||
4. Ensure `debugger` permission is granted in extension settings
|
||||
|
||||
## See Also
|
||||
|
||||
- [Quickstart](quickstart.md) - Getting started
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
High-performance async browser automation using Playwright.
|
||||
Supports shared browser instance for low latency and cross-MCP sharing via CDP.
|
||||
|
||||
Integration with Hanzo Browser Extension:
|
||||
# Start the CDP bridge server
|
||||
python -m hanzo_tools.browser.cdp_bridge_server
|
||||
|
||||
# The browser extension connects to ws://localhost:9223/cdp
|
||||
# hanzo-mcp can then control browser tabs through the extension
|
||||
"""
|
||||
|
||||
from mcp.server import FastMCP
|
||||
@@ -16,6 +23,18 @@ from hanzo_tools.browser.browser_tool import (
|
||||
launch_browser_server,
|
||||
)
|
||||
|
||||
# CDP Bridge for browser extension integration
|
||||
try:
|
||||
from hanzo_tools.browser.cdp_bridge_server import (
|
||||
CDPBridgeServer,
|
||||
CDPBridgeClient,
|
||||
WEBSOCKETS_AVAILABLE as CDP_BRIDGE_AVAILABLE,
|
||||
)
|
||||
except ImportError:
|
||||
CDP_BRIDGE_AVAILABLE = False
|
||||
CDPBridgeServer = None
|
||||
CDPBridgeClient = None
|
||||
|
||||
# Tools list for entry point discovery
|
||||
TOOLS = [BrowserTool]
|
||||
|
||||
@@ -27,6 +46,10 @@ __all__ = [
|
||||
# Browser pool
|
||||
"BrowserPool",
|
||||
"launch_browser_server",
|
||||
# CDP Bridge (for browser extension integration)
|
||||
"CDPBridgeServer",
|
||||
"CDPBridgeClient",
|
||||
"CDP_BRIDGE_AVAILABLE",
|
||||
# Availability check
|
||||
"PLAYWRIGHT_AVAILABLE",
|
||||
# Registration
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
CDP Bridge Server for Hanzo Browser Extension Integration.
|
||||
|
||||
This server acts as a bridge between:
|
||||
1. hanzo-mcp's browser tool (Playwright)
|
||||
2. The Hanzo browser extension (Chrome/Firefox)
|
||||
|
||||
The browser extension connects via WebSocket, and hanzo-mcp can send
|
||||
commands through the extension to control browser tabs.
|
||||
|
||||
Usage:
|
||||
# Start the bridge server (typically on port 9223)
|
||||
python -m hanzo_tools.browser.cdp_bridge_server
|
||||
|
||||
# Or programmatically
|
||||
from hanzo_tools.browser.cdp_bridge_server import CDPBridgeServer
|
||||
server = CDPBridgeServer(port=9223)
|
||||
await server.start()
|
||||
|
||||
Environment Variables:
|
||||
HANZO_CDP_BRIDGE_PORT: Port for the WebSocket server (default: 9223)
|
||||
HANZO_CDP_BRIDGE_HOST: Host to bind to (default: localhost)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
try:
|
||||
import websockets
|
||||
from websockets.server import serve as ws_serve
|
||||
from websockets.server import WebSocketServerProtocol
|
||||
WEBSOCKETS_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKETS_AVAILABLE = False
|
||||
WebSocketServerProtocol = Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CDPBridgeServer:
|
||||
"""WebSocket server that bridges hanzo-mcp and browser extension."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 9223,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.extension_client: Optional[WebSocketServerProtocol] = None
|
||||
self.mcp_clients: set[WebSocketServerProtocol] = set()
|
||||
self.pending_requests: dict[int, asyncio.Future] = {}
|
||||
self.request_id = 0
|
||||
self._server = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the WebSocket server."""
|
||||
if not WEBSOCKETS_AVAILABLE:
|
||||
raise ImportError(
|
||||
"websockets package required for CDP bridge. "
|
||||
"Install with: pip install websockets"
|
||||
)
|
||||
|
||||
self._server = await ws_serve(
|
||||
self._handle_connection,
|
||||
self.host,
|
||||
self.port,
|
||||
)
|
||||
logger.info(f"CDP Bridge Server started on ws://{self.host}:{self.port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the WebSocket server."""
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
logger.info("CDP Bridge Server stopped")
|
||||
|
||||
async def _handle_connection(
|
||||
self,
|
||||
websocket: WebSocketServerProtocol,
|
||||
path: str,
|
||||
) -> None:
|
||||
"""Handle incoming WebSocket connections."""
|
||||
logger.info(f"New connection from {websocket.remote_address} on {path}")
|
||||
|
||||
try:
|
||||
# First message identifies the client type
|
||||
message = await websocket.recv()
|
||||
data = json.loads(message)
|
||||
|
||||
if data.get("type") == "register":
|
||||
role = data.get("role")
|
||||
|
||||
if role == "cdp-provider":
|
||||
# This is the browser extension
|
||||
self.extension_client = websocket
|
||||
logger.info("Browser extension registered as CDP provider")
|
||||
|
||||
# Notify MCP clients
|
||||
for client in self.mcp_clients:
|
||||
await client.send(json.dumps({
|
||||
"type": "provider_connected",
|
||||
"capabilities": data.get("capabilities", [])
|
||||
}))
|
||||
|
||||
elif role == "mcp-client":
|
||||
# This is hanzo-mcp or another MCP tool
|
||||
self.mcp_clients.add(websocket)
|
||||
logger.info("MCP client connected")
|
||||
|
||||
# Let client know if extension is connected
|
||||
await websocket.send(json.dumps({
|
||||
"type": "status",
|
||||
"extension_connected": self.extension_client is not None
|
||||
}))
|
||||
|
||||
# Handle subsequent messages
|
||||
async for message in websocket:
|
||||
await self._route_message(websocket, message)
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"Connection closed: {websocket.remote_address}")
|
||||
finally:
|
||||
# Clean up
|
||||
if websocket == self.extension_client:
|
||||
self.extension_client = None
|
||||
# Notify MCP clients
|
||||
for client in self.mcp_clients:
|
||||
try:
|
||||
await client.send(json.dumps({
|
||||
"type": "provider_disconnected"
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
elif websocket in self.mcp_clients:
|
||||
self.mcp_clients.discard(websocket)
|
||||
|
||||
async def _route_message(
|
||||
self,
|
||||
sender: WebSocketServerProtocol,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Route messages between extension and MCP clients."""
|
||||
data = json.loads(message)
|
||||
|
||||
if sender == self.extension_client:
|
||||
# Message from extension (response or event)
|
||||
if "id" in data and data["id"] in self.pending_requests:
|
||||
# This is a response to a pending request
|
||||
future = self.pending_requests.pop(data["id"])
|
||||
future.set_result(data)
|
||||
elif data.get("type") == "event":
|
||||
# Broadcast event to all MCP clients
|
||||
for client in self.mcp_clients:
|
||||
try:
|
||||
await client.send(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif sender in self.mcp_clients:
|
||||
# Message from MCP client (command)
|
||||
if not self.extension_client:
|
||||
# No extension connected
|
||||
await sender.send(json.dumps({
|
||||
"id": data.get("id"),
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "Browser extension not connected"
|
||||
}
|
||||
}))
|
||||
return
|
||||
|
||||
# Forward to extension and wait for response
|
||||
request_id = data.get("id", self._next_request_id())
|
||||
data["id"] = request_id
|
||||
|
||||
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
self.pending_requests[request_id] = future
|
||||
|
||||
try:
|
||||
await self.extension_client.send(json.dumps(data))
|
||||
|
||||
# Wait for response with timeout
|
||||
response = await asyncio.wait_for(future, timeout=30.0)
|
||||
await sender.send(json.dumps(response))
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
self.pending_requests.pop(request_id, None)
|
||||
await sender.send(json.dumps({
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32001,
|
||||
"message": "Request timeout"
|
||||
}
|
||||
}))
|
||||
except Exception as e:
|
||||
self.pending_requests.pop(request_id, None)
|
||||
await sender.send(json.dumps({
|
||||
"id": request_id,
|
||||
"error": {
|
||||
"code": -32603,
|
||||
"message": str(e)
|
||||
}
|
||||
}))
|
||||
|
||||
def _next_request_id(self) -> int:
|
||||
"""Generate next request ID."""
|
||||
self.request_id += 1
|
||||
return self.request_id
|
||||
|
||||
|
||||
class CDPBridgeClient:
|
||||
"""Client for connecting to CDP Bridge Server from hanzo-mcp."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 9223,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._websocket: Optional[WebSocketServerProtocol] = None
|
||||
self._request_id = 0
|
||||
self._pending: dict[int, asyncio.Future] = {}
|
||||
self._event_handlers: list[Callable] = []
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the CDP bridge server."""
|
||||
if not WEBSOCKETS_AVAILABLE:
|
||||
logger.warning("websockets not available, CDP bridge disabled")
|
||||
return False
|
||||
|
||||
try:
|
||||
import websockets
|
||||
uri = f"ws://{self.host}:{self.port}/cdp"
|
||||
self._websocket = await websockets.connect(uri)
|
||||
|
||||
# Register as MCP client
|
||||
await self._websocket.send(json.dumps({
|
||||
"type": "register",
|
||||
"role": "mcp-client"
|
||||
}))
|
||||
|
||||
# Start message handler
|
||||
asyncio.create_task(self._message_loop())
|
||||
|
||||
logger.info(f"Connected to CDP bridge at {uri}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to connect to CDP bridge: {e}")
|
||||
return False
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from the bridge server."""
|
||||
if self._websocket:
|
||||
await self._websocket.close()
|
||||
self._websocket = None
|
||||
|
||||
async def _message_loop(self) -> None:
|
||||
"""Process incoming messages."""
|
||||
if not self._websocket:
|
||||
return
|
||||
|
||||
try:
|
||||
async for message in self._websocket:
|
||||
data = json.loads(message)
|
||||
|
||||
if "id" in data and data["id"] in self._pending:
|
||||
future = self._pending.pop(data["id"])
|
||||
if "error" in data:
|
||||
future.set_exception(Exception(data["error"]["message"]))
|
||||
else:
|
||||
future.set_result(data.get("result"))
|
||||
|
||||
elif data.get("type") == "event":
|
||||
for handler in self._event_handlers:
|
||||
try:
|
||||
handler(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Message loop error: {e}")
|
||||
|
||||
async def send(self, method: str, params: dict = None) -> Any:
|
||||
"""Send a CDP command and wait for response."""
|
||||
if not self._websocket:
|
||||
raise Exception("Not connected to CDP bridge")
|
||||
|
||||
self._request_id += 1
|
||||
request_id = self._request_id
|
||||
|
||||
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
self._pending[request_id] = future
|
||||
|
||||
await self._websocket.send(json.dumps({
|
||||
"id": request_id,
|
||||
"method": method,
|
||||
"params": params or {}
|
||||
}))
|
||||
|
||||
return await asyncio.wait_for(future, timeout=30.0)
|
||||
|
||||
def on_event(self, handler: Callable) -> None:
|
||||
"""Register an event handler."""
|
||||
self._event_handlers.append(handler)
|
||||
|
||||
# High-level commands
|
||||
|
||||
async def navigate(self, url: str, tab_id: int = None) -> None:
|
||||
"""Navigate to a URL."""
|
||||
await self.send("Page.navigate", {"url": url, "tabId": tab_id})
|
||||
|
||||
async def screenshot(
|
||||
self,
|
||||
tab_id: int = None,
|
||||
full_page: bool = False,
|
||||
format: str = "png",
|
||||
) -> str:
|
||||
"""Take a screenshot, returns base64 data."""
|
||||
result = await self.send("hanzo.screenshot", {
|
||||
"tabId": tab_id,
|
||||
"fullPage": full_page,
|
||||
"format": format
|
||||
})
|
||||
return result.get("data", "")
|
||||
|
||||
async def click(self, selector: str, tab_id: int = None) -> bool:
|
||||
"""Click an element by selector."""
|
||||
result = await self.send("hanzo.click", {
|
||||
"selector": selector,
|
||||
"tabId": tab_id
|
||||
})
|
||||
return result.get("success", False)
|
||||
|
||||
async def fill(self, selector: str, value: str, tab_id: int = None) -> bool:
|
||||
"""Fill an input element."""
|
||||
result = await self.send("hanzo.fill", {
|
||||
"selector": selector,
|
||||
"value": value,
|
||||
"tabId": tab_id
|
||||
})
|
||||
return result.get("success", False)
|
||||
|
||||
async def evaluate(self, expression: str, tab_id: int = None) -> Any:
|
||||
"""Evaluate JavaScript in the page."""
|
||||
return await self.send("Runtime.evaluate", {
|
||||
"expression": expression,
|
||||
"tabId": tab_id
|
||||
})
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run the CDP bridge server."""
|
||||
host = os.environ.get("HANZO_CDP_BRIDGE_HOST", "localhost")
|
||||
port = int(os.environ.get("HANZO_CDP_BRIDGE_PORT", "9223"))
|
||||
|
||||
server = CDPBridgeServer(host=host, port=port)
|
||||
await server.start()
|
||||
|
||||
print(f"CDP Bridge Server running on ws://{host}:{port}")
|
||||
print("Waiting for browser extension to connect...")
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
await asyncio.Future() # Run forever
|
||||
except KeyboardInterrupt:
|
||||
print("\nShutting down...")
|
||||
await server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user