Files
sovereign_browser/plans/mcp-server.md
Laan Tungir 372339aa35 v0.0.12 - MCP server: 100 tools, Streamable HTTP transport, favicons, refresh button, webview sizing fix
- MCP protocol compliance: ping, resources/list, prompts/list, logging/setLevel, 202 notifications
- Screenshot tool: WebKit snapshot to PNG to base64 MCP image content
- Session management: Mcp-Session-Id header, SSE response format, GET/DELETE endpoints
- Phase 3: 70 new tools across 8 batches (extended interaction, get/state, find elements, wait/batch, cookies/storage, mouse/clipboard/settings, frames/dialogs/debug, complex)
- Favicon support: enabled WebKitFaviconDatabase, notify::favicon signal handler
- Tab UI: favicons in tab labels, evenly distributed tabs, immediate title from URL host
- Refresh button: left-click reload, right-click hard reload menu (bypass cache, clear site data)
- Webview sizing fix: gtk_widget_set_vexpand/hexpand to prevent 1px height blank page
- Updated Roo Code MCP config (100 tools in alwaysAllow)
- Updated plans: mcp-server.md, agent-tools.md, phase3-tools.md
2026-07-12 09:20:14 -04:00

17 KiB

MCP Server Implementation Plan

Overview

Add an MCP (Model Context Protocol) endpoint to sovereign_browser's existing agent server. This allows AI assistants (Roo Code, Claude Code, Cursor, etc.) to control the browser directly — no custom scripts, no CLI, no separate process. The browser exposes its tools as MCP tools, and the AI assistant calls them like any other MCP tool.

Architecture

AI Assistant (Roo Code, Claude Code, etc.)
    ↕ MCP Streamable HTTP (JSON-RPC over SSE)
sovereign_browser (SoupServer on port 17777)
    ├── /agent    → WebSocket (existing, for scripts/CLI)
    ├── /mcp      → MCP Streamable HTTP (new, for AI assistants)
    └── /         → HTTP status (existing)

Both /agent (WebSocket) and /mcp (MCP) use the same internal agent_tools_dispatch() function — same code path, same tools, same behavior. This follows the debugging principle: no parallel implementations.

MCP Streamable HTTP transport

MCP supports a "Streamable HTTP" transport where the client sends HTTP POST requests and the server responds with Server-Sent Events (SSE). This is ideal for us — we already have a SoupServer running.

Protocol flow

  1. Client sends POST to /mcp with JSON-RPC body:

    {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
      "protocolVersion": "2024-11-05",
      "capabilities": {},
      "clientInfo": {"name": "roo-code", "version": "1.0"}
    }}
    
  2. Server responds with SSE stream containing the result:

    event: message
    data: {"jsonrpc": "2.0", "id": 1, "result": {
      "protocolVersion": "2024-11-05",
      "capabilities": {"tools": {}},
      "serverInfo": {"name": "sovereign-browser", "version": "0.0.10"}
    }}
    
  3. Client sends tools/list:

    {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
    
  4. Server responds with the full tool catalog (30 tools).

  5. Client sends tools/call:

    {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
      "name": "snapshot",
      "arguments": {"interactive": true, "compact": true}
    }}
    
  6. Server responds with the tool result.

Session management

MCP Streamable HTTP uses a session ID (returned in the Mcp-Session-Id header from initialize). The client includes this header in subsequent requests. We store session state (which is minimal — just the session ID and whether the client has initialized).

Tool catalog

Each MCP tool has: name, description, and inputSchema (JSON Schema).

Login tools

{
  "name": "login_status",
  "description": "Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
  "inputSchema": {"type": "object", "properties": {}}
}
{
  "name": "login",
  "description": "Log in to the browser with a Nostr identity. Methods: 'local' (nsec or hex privkey), 'seed' (BIP-39 mnemonic), 'readonly' (npub), 'nip46' (bunker:// URL), 'nsigner' (hardware signer). Must be called before browser tools work.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "method": {"type": "string", "enum": ["local", "seed", "readonly", "nip46", "nsigner"]},
      "nsec": {"type": "string", "description": "nsec1... string (method: local)"},
      "privkey_hex": {"type": "string", "description": "64-char hex private key (method: local)"},
      "mnemonic": {"type": "string", "description": "12 or 24 BIP-39 words (method: seed)"},
      "account": {"type": "integer", "default": 0, "description": "Account index (method: seed)"},
      "npub": {"type": "string", "description": "npub1... string (method: readonly)"},
      "pubkey_hex": {"type": "string", "description": "64-char hex pubkey (method: readonly)"},
      "bunker_url": {"type": "string", "description": "bunker:// URL (method: nip46)"},
      "transport": {"type": "string", "enum": ["serial", "unix", "tcp", "qrexec"], "description": "Transport type (method: nsigner)"},
      "device": {"type": "string", "description": "Device path, socket name, host:port, or qube name (method: nsigner)"},
      "service": {"type": "string", "default": "qubes.NsignerRpc", "description": "Qrexec service name (method: nsigner, transport: qrexec)"},
      "index": {"type": "integer", "default": 0, "description": "Key index (method: nsigner)"}
    },
    "required": ["method"]
  }
}
{
  "name": "logout",
  "description": "Log out and clear the current Nostr identity.",
  "inputSchema": {"type": "object", "properties": {}}
}
{
  "name": "switch_identity",
  "description": "Switch to a new Nostr identity. Same parameters as login. Frees the old signer first.",
  "inputSchema": {"type": "object", "properties": {"method": {"type": "string"}, "nsec": {"type": "string"}, "privkey_hex": {"type": "string"}, "mnemonic": {"type": "string"}, "npub": {"type": "string"}, "bunker_url": {"type": "string"}, "transport": {"type": "string"}, "device": {"type": "string"}, "index": {"type": "integer"}}, "required": ["method"]}
}

Navigation tools

{"name": "open", "description": "Navigate the active tab to a URL.", "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}
{"name": "back", "description": "Go back in browser history.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "forward", "description": "Go forward in browser history.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "reload", "description": "Reload the current page (bypassing cache).", "inputSchema": {"type": "object", "properties": {}}}
{"name": "get_url", "description": "Get the current URL of the active tab.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "get_title", "description": "Get the page title of the active tab.", "inputSchema": {"type": "object", "properties": {}}}

Snapshot & inspection tools

{
  "name": "snapshot",
  "description": "Get the accessibility tree of the current page with element refs. Returns a text tree and a ref map. Use refs (e.g. @e1) to interact with elements by click, fill, etc. Call this after navigation or page changes to see what's on the page.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "interactive": {"type": "boolean", "default": true, "description": "Only show interactive elements (links, buttons, inputs)"},
      "compact": {"type": "boolean", "default": true, "description": "Remove empty structural elements"}
    }
  }
}
{
  "name": "get_text",
  "description": "Get the text content of an element by ref or CSS selector.",
  "inputSchema": {"type": "object", "properties": {"ref": {"type": "string", "description": "Element ref from snapshot (e.g. @e1)"}, "selector": {"type": "string", "description": "CSS selector"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}
}
{
  "name": "get_html",
  "description": "Get the innerHTML of an element by ref or CSS selector.",
  "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}
}
{
  "name": "get_attr",
  "description": "Get an attribute of an element by ref or CSS selector.",
  "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "attr": {"type": "string", "description": "Attribute name (e.g. href, src, class)"}}, "required": ["attr"]}
}
{
  "name": "eval",
  "description": "Run JavaScript in the current page and return the result. Use for custom inspection or interaction not covered by other tools.",
  "inputSchema": {"type": "object", "properties": {"script": {"type": "string", "description": "JavaScript to execute"}}, "required": ["script"]}
}

Interaction tools

{"name": "click", "description": "Click an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "fill", "description": "Clear an input and fill it with a value.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "value": {"type": "string"}}, "required": ["value"]}}
{"name": "type", "description": "Type text into an element (appends to existing value).", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "value": {"type": "string"}}, "required": ["value"]}}
{"name": "press", "description": "Press a keyboard key (e.g. Enter, Tab, Escape).", "inputSchema": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]}}
{"name": "scroll", "description": "Scroll the page in a direction.", "inputSchema": {"type": "object", "properties": {"direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer", "default": 500}}, "required": ["direction"]}}
{"name": "hover", "description": "Hover over an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "focus", "description": "Focus an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "close", "description": "Close the active tab.", "inputSchema": {"type": "object", "properties": {}}}

Tab tools

{"name": "tab_list", "description": "List all open tabs with their URLs and titles.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "tab_new", "description": "Open a new tab, optionally with a URL.", "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}}}
{"name": "tab_switch", "description": "Switch to a tab by index.", "inputSchema": {"type": "object", "properties": {"index": {"type": "integer"}}, "required": ["index"]}}
{"name": "tab_close", "description": "Close a tab by index. If no index, closes the active tab.", "inputSchema": {"type": "object", "properties": {"index": {"type": "integer"}}}}

Wait tools

{"name": "wait", "description": "Wait for a specified number of milliseconds.", "inputSchema": {"type": "object", "properties": {"ms": {"type": "integer", "default": 1000}}}}
{"name": "wait_for", "description": "Wait for an element to appear on the page.", "inputSchema": {"type": "object", "properties": {"selector": {"type": "string"}, "timeout": {"type": "integer", "default": 10000}}, "required": ["selector"]}}

Implementation

New file: src/agent_mcp.h / src/agent_mcp.c

/*
 * MCP (Model Context Protocol) server endpoint.
 * Implements the Streamable HTTP transport on top of our existing
 * SoupServer. Exposes browser tools as MCP tools for AI assistants.
 */

/* Register the MCP handler at /mcp on the given SoupServer. */
void agent_mcp_register(SoupServer *server);

MCP request handling

The MCP handler at /mcp processes HTTP POST requests with JSON-RPC bodies. It handles three methods:

  1. initialize — returns server info and capabilities, creates a session, returns Mcp-Session-Id header
  2. tools/list — returns the tool catalog (all 30 tools with schemas)
  3. tools/call — dispatches to agent_tools_dispatch() (same as WebSocket)
  4. ping — health check, returns empty result
  5. resources/list / resources/templates/list — returns empty lists (no resources exposed)
  6. prompts/list — returns empty list (no prompts exposed)
  7. logging/setLevel — acknowledged, no filtering applied
  8. notifications/* — returns 202 Accepted with no body
  9. GET /mcp — returns SSE content-type with : connected comment
  10. DELETE /mcp — terminates the session (requires Mcp-Session-Id header)

For tools/call, the MCP handler:

  1. Extracts the tool name and arguments from the JSON-RPC params
  2. Constructs a cJSON request object (same format as WebSocket)
  3. Calls agent_tools_dispatch(request, NULL) — passing NULL for the connection since MCP uses HTTP response, not WebSocket
  4. For sync tools: returns the response immediately as JSON-RPC result
  5. For async tools (snapshot, eval, etc.): this is the tricky part...

Async tool handling for MCP

The async tools (snapshot, eval, get_text, etc.) send their response through a WebSocket connection callback. But MCP uses HTTP responses, not WebSocket. We need a way to capture the async result and return it in the HTTP response.

Approach: Use a GMainLoop polling approach (which works for HTTP since we're not inside a WebSocket callback). The MCP handler:

  1. Creates a temporary "result holder" struct
  2. Calls agent_js_eval_async() with a custom callback that stores the result in the holder and quits a GMainLoop
  3. Runs a GMainLoop with timeout
  4. When the JS completes, the callback stores the result
  5. The handler returns the result as the HTTP response

This works because the MCP HTTP handler is a regular SoupServer callback (not a WebSocket message callback), so the GMainLoop polling approach works correctly.

Tool catalog generation

The tool catalog is a static cJSON array built once at startup. Each tool entry has:

  • name: tool name string
  • description: human-readable description
  • inputSchema: JSON Schema object

Modified files

  • src/agent_mcp.h / src/agent_mcp.c — new, MCP handler
  • src/agent_server.c — call agent_mcp_register() in agent_server_start()
  • src/agent_tools.c — add a variant of dispatch that works with a result-holder instead of a WebSocket connection (for MCP async tools)
  • Makefile — add src/agent_mcp.c to SRC

Roo Code configuration

To use the browser as an MCP server in Roo Code, add this to the MCP configuration:

{
  "mcpServers": {
    "sovereign-browser": {
      "url": "http://localhost:17777/mcp",
      "transport": "streamable-http"
    }
  }
}

Implementation order

  1. Create src/agent_mcp.h / src/agent_mcp.c with the tool catalog
  2. Implement initialize and tools/list handlers
  3. Implement tools/call for sync tools (login, open, tab_list, etc.)
  4. Implement tools/call for async tools (snapshot, eval, get_text, etc.) using the GMainLoop polling approach
  5. Register the MCP handler in agent_server_start()
  6. Test with Roo Code or a manual MCP client
  7. Update Makefile and docs

Implementation Status

Completed — all items in the implementation order above are done.

What's implemented

  • MCP Streamable HTTP transport on /mcp (port 17777), coexisting with the existing /agent WebSocket endpoint.
  • Session management: initialize creates a session and returns a Mcp-Session-Id header. Subsequent requests are validated against the session store. Unknown/expired sessions return 404. Sessions have a 60-second idle timeout.
  • SSE responses: all JSON-RPC responses (requests with an id) are wrapped in event: message\ndata: <json>\n\n SSE format with Content-Type: text/event-stream.
  • GET /mcp: returns a text/event-stream response with a : connected comment (satisfies clients probing for SSE push support).
  • DELETE /mcp: terminates a session by Mcp-Session-Id header. Returns 200 on success, 400 if header missing, 404 if session unknown.
  • Notifications: notifications/* and any request without an id return 202 Accepted with no body.
  • Tool catalog: 30 tools exposed (29 original + screenshot). The screenshot tool returns an MCP image content block (type: "image", base64 PNG, mimeType: "image/png") instead of a text block.
  • Lenient mode: requests without a Mcp-Session-Id header are accepted with a warning (backward compatibility with older clients).
  • Roo Code integration: configured in mcp_settings.json with transport: "streamable-http" and all 30 tools in alwaysAllow.

Test results (2026-07-12)

All 10 integration tests pass:

Test Description Result
1 Initialize — returns Mcp-Session-Id header + SSE body Pass
2 tools/list — 30 tools including screenshot Pass
3 ping with session — SSE empty result Pass
4 resources/list — empty resources array Pass
5 prompts/list — empty prompts array Pass
6 notifications/initialized — 202 Accepted Pass
7 GET /mcptext/event-stream content type Pass
8 DELETE /mcp — 200 OK Pass
9 ping after DELETE — 404 (session terminated) Pass
10 login_status tool call — returns login state Pass

Files