Skip to content

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 reads MCP servers from its claude_desktop_config.json. On macOS it lives at:

~/Library/Application Support/Claude/claude_desktop_config.json

On Windows it lives at:

%APPDATA%\Claude\claude_desktop_config.json

Add 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.

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 length
prefix per frame. Same-user only: the browser will close the socket if
your effective UID does not match its own.
"""
import json
import os
import socket
import struct
import sys
from 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:

Terminal window
$ python3 maho_mcp_demo.py
initialize: {'jsonrpc': '2.0', 'id': 1, 'result': {...}}
tab_list: {'jsonrpc': '2.0', 'id': 2, 'result': {'content': [...]}}
// 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/"
}
}
}
{
"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.

// 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" }
}
}

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.