Examples
Three configurations for three shapes of client: a hosted desktop agent, a CLI-driven agent, and a raw script that speaks the wire protocol directly.
All three assume Maho is installed and running, and that maho is on your PATH. If it is not, use the absolute path to the maho binary in the config below.
Claude Desktop
Section titled “Claude Desktop”Claude Desktop reads MCP servers from its claude_desktop_config.json. On macOS it lives at:
~/Library/Application Support/Claude/claude_desktop_config.jsonOn Windows it lives at:
%APPDATA%\Claude\claude_desktop_config.jsonAdd the maho entry to the mcpServers map:
{ "mcpServers": { "maho": { "command": "maho", "args": ["mcp"] } }}Restart Claude Desktop. In the model picker, the Maho tools appear alongside any other MCP tools you have installed. First-time use in a conversation may trigger the allowed-domains prompt in the browser window.
Codex reads MCP servers from ~/.codex/config.toml. Add:
[mcp_servers.maho]command = "maho"args = ["mcp"]Restart the Codex CLI. Codex enumerates the tool list on startup and surfaces browser_tab_list, browser_navigate, and the rest to the model as callable tools.
Custom Python client
Section titled “Custom Python client”For everything not covered by an off-the-shelf MCP client, the wire protocol is small enough to speak directly. Here is a minimal client that connects, initializes, lists tabs, and disconnects. It runs on macOS and Linux.
#!/usr/bin/env python3"""Minimal Maho Agent Protocol client.
Speaks JSON-RPC 2.0 over a Unix domain socket. 4-byte big-endian lengthprefix per frame. Same-user only: the browser will close the socket ifyour effective UID does not match its own."""
import jsonimport osimport socketimport structimport sysfrom pathlib import Path
def socket_path() -> Path: if sys.platform == "darwin": return Path.home() / "Library" / "Application Support" / "Maho" / "maho.sock" xdg = os.environ.get("XDG_STATE_HOME") base = Path(xdg) if xdg else Path.home() / ".local" / "state" return base / "maho" / "maho.sock"
def send(sock: socket.socket, msg: dict) -> None: data = json.dumps(msg).encode("utf-8") sock.sendall(struct.pack(">I", len(data)) + data)
def recv(sock: socket.socket) -> dict: header = _recv_exact(sock, 4) (length,) = struct.unpack(">I", header) body = _recv_exact(sock, length) return json.loads(body.decode("utf-8"))
def _recv_exact(sock: socket.socket, n: int) -> bytes: buf = bytearray() while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("socket closed mid-frame") buf.extend(chunk) return bytes(buf)
def main() -> None: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.connect(str(socket_path()))
send(sock, { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "example-python", "version": "0.1.0"}, }, }) print("initialize:", recv(sock))
send(sock, { "jsonrpc": "2.0", "method": "notifications/initialized", "params": {}, })
send(sock, { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "browser_tab_list", "arguments": {}}, }) print("tab_list:", recv(sock))
sock.close()
if __name__ == "__main__": main()Save as maho_mcp_demo.py and run:
$ python3 maho_mcp_demo.pyinitialize: {'jsonrpc': '2.0', 'id': 1, 'result': {...}}tab_list: {'jsonrpc': '2.0', 'id': 2, 'result': {'content': [...]}}Common patterns
Section titled “Common patterns”Set allowed domains, then navigate
Section titled “Set allowed domains, then navigate”// 1. Approve the domain for this session{ "jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": { "name": "browser_set_allowed_domains", "arguments": { "domains": ["example.com"] } }}
// 2. Navigate an existing tab there{ "jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": { "name": "browser_navigate", "arguments": { "tab_id": "tab_1a2b", "url": "https://example.com/" } }}call(sock, 10, "browser_set_allowed_domains", {"domains": ["example.com"]})call(sock, 11, "browser_navigate", { "tab_id": "tab_1a2b", "url": "https://example.com/",})Read the active tab as Markdown
Section titled “Read the active tab as Markdown”{ "jsonrpc": "2.0", "id": 20, "method": "tools/call", "params": { "name": "browser_page_content", "arguments": { "tab_id": "tab_1a2b", "format": "markdown" } }}Response body under result.content[0].text is a JSON string. Parse it and read the content field.
Hold a lease during a multi-step task
Section titled “Hold a lease during a multi-step task”// Acquire{ "jsonrpc": "2.0", "id": 30, "method": "tools/call", "params": { "name": "browser_acquire_lease", "arguments": { "tab_id": "tab_1a2b", "ttl_seconds": 60 } }}
// Heartbeat every 20 s while working{ "jsonrpc": "2.0", "id": 31, "method": "tools/call", "params": { "name": "browser_heartbeat_lease", "arguments": { "lease_id": "lease_7f" } }}
// Release when done{ "jsonrpc": "2.0", "id": 32, "method": "tools/call", "params": { "name": "browser_release_lease", "arguments": { "lease_id": "lease_7f" } }}Troubleshooting
Section titled “Troubleshooting”Connection refused / socket not found. The browser is not running, or is running under a different user. The socket only exists while Maho is open.
-32001 not_authenticated. You are sending tool calls before initialize has completed. Either wait for the initialize response, or make sure your MCP client library performs the handshake automatically.
-32010 domain_approval_required. Call browser_set_allowed_domains first. The user has to confirm the prompt for the allowlist to take effect.
-32011 lease_conflict. Another session is holding the tab. Either wait, or acquire with force_steal: true and accept that the previous holder will observe the steal.