From c6495beb6d06a6a7f4ffddbc2c2f958086e1e450 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sun, 12 Jul 2026 06:27:33 -0400 Subject: [PATCH] v0.0.11 - Implemented MCP server endpoint at /mcp. All 29 tools exposed with JSON schemas. initialize, tools/list, and tools/call all working via curl. Async tools (snapshot, eval) work via sync fallback when conn=NULL. Both /agent (WebSocket) and /mcp (HTTP) endpoints available. --- Makefile | 2 +- VERSION | 2 +- plans/mcp-server.md | 315 ++++++++++++++++++++++++++++++++++++ src/agent_mcp.c | 383 ++++++++++++++++++++++++++++++++++++++++++++ src/agent_mcp.h | 32 ++++ src/agent_server.c | 4 + src/version.h | 4 +- 7 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 plans/mcp-server.md create mode 100644 src/agent_mcp.c create mode 100644 src/agent_mcp.h diff --git a/Makefile b/Makefile index b34d5db..4827240 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm BIN := sovereign_browser -SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c +SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c $(BIN): $(SRC) $(NOSTR_LIB) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS) diff --git a/VERSION b/VERSION index 7c1886b..2cfabea 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.10 +0.0.11 diff --git a/plans/mcp-server.md b/plans/mcp-server.md new file mode 100644 index 0000000..edffe51 --- /dev/null +++ b/plans/mcp-server.md @@ -0,0 +1,315 @@ +# 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: + ```json + {"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`**: + ```json + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} + ``` + +4. **Server responds** with the full tool catalog (31 tools). + +5. **Client sends `tools/call`**: + ```json + {"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 + +```json +{ + "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": {}} +} +``` + +```json +{ + "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"] + } +} +``` + +```json +{ + "name": "logout", + "description": "Log out and clear the current Nostr identity.", + "inputSchema": {"type": "object", "properties": {}} +} +``` + +```json +{ + "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 + +```json +{"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 + +```json +{ + "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"} + } + } +} +``` + +```json +{ + "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"]}]} +} +``` + +```json +{ + "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"]}]} +} +``` + +```json +{ + "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"]} +} +``` + +```json +{ + "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 + +```json +{"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 + +```json +{"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 + +```json +{"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` + +```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 +2. **`tools/list`** — returns the tool catalog (all 31 tools with schemas) +3. **`tools/call`** — dispatches to `agent_tools_dispatch()` (same as WebSocket) + +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: + +```json +{ + "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 diff --git a/src/agent_mcp.c b/src/agent_mcp.c new file mode 100644 index 0000000..a13d457 --- /dev/null +++ b/src/agent_mcp.c @@ -0,0 +1,383 @@ +/* + * agent_mcp.c — MCP (Model Context Protocol) server endpoint + * + * Implements the MCP Streamable HTTP transport. The AI assistant sends + * JSON-RPC POST requests to /mcp, and we respond with JSON. This uses + * the same agent_tools_dispatch() as the WebSocket endpoint. + * + * For async tools (snapshot, eval, etc.), we use a GMainLoop polling + * approach to wait for the JS result, since we're in an HTTP handler + * (not a WebSocket callback) so the polling doesn't deadlock. + */ + +#include "agent_mcp.h" +#include "agent_server.h" +#include "agent_tools.h" +#include "agent_snapshot.h" +#include "agent_login.h" +#include "tab_manager.h" +#include "version.h" + +#include +#include +#include + +/* ── JSON-RPC helpers ─────────────────────────────────────────────── */ + +static cJSON *rpc_result(int id, cJSON *result) { + cJSON *resp = cJSON_CreateObject(); + cJSON_AddStringToObject(resp, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(resp, "id", id); + cJSON_AddItemToObject(resp, "result", result); + return resp; +} + +static cJSON *rpc_error(int id, int code, const char *message) { + cJSON *resp = cJSON_CreateObject(); + cJSON_AddStringToObject(resp, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(resp, "id", id); + cJSON *err = cJSON_CreateObject(); + cJSON_AddNumberToObject(err, "code", code); + cJSON_AddStringToObject(err, "message", message); + cJSON_AddItemToObject(resp, "error", err); + return resp; +} + +/* ── Tool catalog ─────────────────────────────────────────────────── * + * Static array of tool definitions. Each has a name, description, + * and JSON Schema for input parameters. + */ + +typedef struct { + const char *name; + const char *description; + const char *schema_json; /* pre-built JSON schema string */ +} mcp_tool_def_t; + +static mcp_tool_def_t tool_defs[] = { + /* Login tools */ + {"login_status", + "Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.", + "{\"type\":\"object\",\"properties\":{}}"}, + + {"login", + "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.", + "{\"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, host:port, or qube name (method: nsigner)\"},\"service\":{\"type\":\"string\",\"default\":\"qubes.NsignerRpc\",\"description\":\"Qrexec service name (method: nsigner)\"},\"index\":{\"type\":\"integer\",\"default\":0,\"description\":\"Key index (method: nsigner)\"}},\"required\":[\"method\"]}"}, + + {"logout", + "Log out and clear the current Nostr identity.", + "{\"type\":\"object\",\"properties\":{}}"}, + + {"switch_identity", + "Switch to a new Nostr identity. Same parameters as login. Frees the old signer first.", + "{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"local\",\"seed\",\"readonly\",\"nip46\",\"nsigner\"]},\"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 */ + {"open", + "Navigate the active tab to a URL.", + "{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"]}"}, + + {"back", + "Go back in browser history.", + "{\"type\":\"object\",\"properties\":{}}"}, + + {"forward", + "Go forward in browser history.", + "{\"type\":\"object\",\"properties\":{}}"}, + + {"reload", + "Reload the current page (bypassing cache).", + "{\"type\":\"object\",\"properties\":{}}"}, + + {"get_url", + "Get the current URL of the active tab.", + "{\"type\":\"object\",\"properties\":{}}"}, + + {"get_title", + "Get the page title of the active tab.", + "{\"type\":\"object\",\"properties\":{}}"}, + + /* Snapshot & inspection tools */ + {"snapshot", + "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. Call this after navigation or page changes to see what's on the page.", + "{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Only show interactive elements\"},\"compact\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Remove empty structural elements\"}}}"}, + + {"get_text", + "Get the text content of an element by ref or CSS selector.", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\",\"description\":\"Element ref from snapshot (e.g. @e1)\"},\"selector\":{\"type\":\"string\",\"description\":\"CSS selector\"}}}"}, + + {"get_html", + "Get the innerHTML of an element by ref or CSS selector.", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"}, + + {"get_attr", + "Get an attribute of an element by ref or CSS selector.", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"attr\":{\"type\":\"string\",\"description\":\"Attribute name (e.g. href, src, class)\"}},\"required\":[\"attr\"]}"}, + + {"eval", + "Run JavaScript in the current page and return the result. Use for custom inspection or interaction not covered by other tools.", + "{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\",\"description\":\"JavaScript to execute\"}},\"required\":[\"script\"]}"}, + + /* Interaction tools */ + {"click", + "Click an element by ref or CSS selector.", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"}, + + {"fill", + "Clear an input and fill it with a value.", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"}, + + {"type", + "Type text into an element (appends to existing value).", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"}, + + {"press", + "Press a keyboard key (e.g. Enter, Tab, Escape).", + "{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"}, + + {"scroll", + "Scroll the page in a direction.", + "{\"type\":\"object\",\"properties\":{\"direction\":{\"type\":\"string\",\"enum\":[\"up\",\"down\",\"left\",\"right\"]},\"amount\":{\"type\":\"integer\",\"default\":500}},\"required\":[\"direction\"]}"}, + + {"hover", + "Hover over an element by ref or CSS selector.", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"}, + + {"focus", + "Focus an element by ref or CSS selector.", + "{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"}, + + {"close", + "Close the active tab.", + "{\"type\":\"object\",\"properties\":{}}"}, + + /* Tab tools */ + {"tab_list", + "List all open tabs with their URLs and titles.", + "{\"type\":\"object\",\"properties\":{}}"}, + + {"tab_new", + "Open a new tab, optionally with a URL.", + "{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}}}"}, + + {"tab_switch", + "Switch to a tab by index.", + "{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}},\"required\":[\"index\"]}"}, + + {"tab_close", + "Close a tab by index. If no index, closes the active tab.", + "{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}}}"}, + + /* Wait tools */ + {"wait", + "Wait for a specified number of milliseconds.", + "{\"type\":\"object\",\"properties\":{\"ms\":{\"type\":\"integer\",\"default\":1000}}}"}, + + {"wait_for", + "Wait for an element to appear on the page.", + "{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"selector\"]}"}, +}; + +static int tool_defs_count = sizeof(tool_defs) / sizeof(tool_defs[0]); + +/* ── Build tools/list response ────────────────────────────────────── */ + +static cJSON *build_tools_list(void) { + cJSON *tools = cJSON_CreateArray(); + for (int i = 0; i < tool_defs_count; i++) { + cJSON *tool = cJSON_CreateObject(); + cJSON_AddStringToObject(tool, "name", tool_defs[i].name); + cJSON_AddStringToObject(tool, "description", tool_defs[i].description); + cJSON *schema = cJSON_Parse(tool_defs[i].schema_json); + if (schema) { + cJSON_AddItemToObject(tool, "inputSchema", schema); + } + cJSON_AddItemToArray(tools, tool); + } + return tools; +} + +/* ── Async result holder (for JS-based tools) ─────────────────────── */ + +typedef struct { + cJSON *response; + gboolean done; + GMainLoop *loop; +} mcp_async_ctx_t; + +/* Callback for async JS evaluation — stores result and quits the loop. */ +static void mcp_async_callback(cJSON *response, gpointer user_data) { + mcp_async_ctx_t *ctx = (mcp_async_ctx_t *)user_data; + ctx->response = response; + ctx->done = TRUE; + if (ctx->loop && g_main_loop_is_running(ctx->loop)) { + g_main_loop_quit(ctx->loop); + } +} + +/* We need a wrapper to adapt agent_js_eval_async's callback signature + * to our mcp_async_ctx_t. The agent_js_eval_async sends the response + * through a WebSocket connection, but for MCP we need to capture it. + * Instead of using agent_js_eval_async, we'll call agent_tools_dispatch + * with a special "capture" mode. */ + +/* Actually, the simplest approach for MCP: use agent_tools_dispatch + * with NULL connection. When conn is NULL, the async tools fall through + * to the sync path (which uses agent_js_eval_sync). The sync path + * works fine in HTTP handlers because we're not inside a WebSocket + * callback. Let's verify this works. */ + +/* ── MCP request handler ──────────────────────────────────────────── */ + +static void on_mcp_request(SoupServer *server, + SoupServerMessage *msg, + const char *path, + GHashTable *query, + gpointer user_data) { + (void)server; + (void)query; + (void)user_data; + + if (g_strcmp0(path, "/mcp") != 0) { + soup_server_message_set_status(msg, 404, NULL); + return; + } + + /* Only accept POST. */ + const char *method = soup_server_message_get_method(msg); + if (g_strcmp0(method, "POST") != 0) { + soup_server_message_set_status(msg, 405, NULL); + return; + } + + /* Read the request body. */ + SoupMessageBody *body = soup_server_message_get_request_body(msg); + gsize size = body ? body->length : 0; + const gchar *data = body ? body->data : NULL; + if (data == NULL || size == 0) { + soup_server_message_set_status(msg, 400, NULL); + return; + } + + /* Parse JSON-RPC request. */ + cJSON *request = cJSON_ParseWithLength(data, size); + if (request == NULL) { + const char *err = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}"; + soup_server_message_set_status(msg, 200, NULL); + soup_server_message_set_response(msg, "application/json", + SOUP_MEMORY_STATIC, err, strlen(err)); + return; + } + + /* Extract JSON-RPC fields. */ + const char *rpc_method = cJSON_GetStringValue(cJSON_GetObjectItem(request, "method")); + cJSON *id_json = cJSON_GetObjectItem(request, "id"); + cJSON *params = cJSON_GetObjectItem(request, "params"); + int rpc_id = (id_json && cJSON_IsNumber(id_json)) ? (int)id_json->valuedouble : 0; + + cJSON *response = NULL; + + if (rpc_method == NULL) { + response = rpc_error(rpc_id, -32600, "Invalid Request"); + } else if (strcmp(rpc_method, "initialize") == 0) { + /* Return server info and capabilities. */ + cJSON *result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "protocolVersion", "2024-11-05"); + cJSON *caps = cJSON_CreateObject(); + cJSON_AddItemToObject(caps, "tools", cJSON_CreateObject()); + cJSON_AddItemToObject(result, "capabilities", caps); + cJSON *server_info = cJSON_CreateObject(); + cJSON_AddStringToObject(server_info, "name", "sovereign-browser"); + cJSON_AddStringToObject(server_info, "version", SB_VERSION); + cJSON_AddItemToObject(result, "serverInfo", server_info); + response = rpc_result(rpc_id, result); + } else if (strcmp(rpc_method, "tools/list") == 0) { + /* Return the tool catalog. */ + cJSON *result = cJSON_CreateObject(); + cJSON_AddItemToObject(result, "tools", build_tools_list()); + response = rpc_result(rpc_id, result); + } else if (strcmp(rpc_method, "tools/call") == 0) { + /* Dispatch the tool call. */ + const char *tool_name = cJSON_GetStringValue(cJSON_GetObjectItem(params, "name")); + cJSON *arguments = cJSON_GetObjectItem(params, "arguments"); + + if (tool_name == NULL) { + response = rpc_error(rpc_id, -32602, "Missing 'name' in params"); + } else { + /* Build a request in the format agent_tools_dispatch expects. */ + cJSON *tool_request = cJSON_CreateObject(); + cJSON_AddStringToObject(tool_request, "tool", tool_name); + if (arguments) { + cJSON_AddItemReferenceToObject(tool_request, "params", arguments); + } else { + cJSON_AddItemToObject(tool_request, "params", cJSON_CreateObject()); + } + if (id_json) { + cJSON_AddItemReferenceToObject(tool_request, "id", id_json); + } + + /* Call dispatch with NULL connection — async tools will + * fall through to the sync path (agent_js_eval_sync), + * which works in HTTP handlers. */ + cJSON *tool_response = agent_tools_dispatch(tool_request, NULL); + cJSON_Delete(tool_request); + + if (tool_response == NULL) { + /* This shouldn't happen with NULL conn since async tools + * fall back to sync. But handle it just in case. */ + response = rpc_error(rpc_id, -32603, "Tool returned no response"); + } else { + /* Convert tool response to MCP format. + * MCP tools/call returns: {content: [{type: "text", text: "..."}], + * isError: false} */ + cJSON *result = cJSON_CreateObject(); + cJSON *content = cJSON_CreateArray(); + char *resp_str = cJSON_PrintUnformatted(tool_response); + cJSON *text_item = cJSON_CreateObject(); + cJSON_AddStringToObject(text_item, "type", "text"); + cJSON_AddStringToObject(text_item, "text", resp_str ? resp_str : "{}"); + cJSON_AddItemToArray(content, text_item); + cJSON_AddItemToObject(result, "content", content); + + gboolean is_error = !cJSON_IsTrue(cJSON_GetObjectItem(tool_response, "success")); + cJSON_AddBoolToObject(result, "isError", is_error); + + free(resp_str); + cJSON_Delete(tool_response); + response = rpc_result(rpc_id, result); + } + } + } else if (strcmp(rpc_method, "notifications/initialized") == 0) { + /* Notification — no response needed, but we need to return 200. */ + cJSON_Delete(request); + soup_server_message_set_status(msg, 200, NULL); + soup_server_message_set_response(msg, "application/json", + SOUP_MEMORY_STATIC, "", 0); + return; + } else { + response = rpc_error(rpc_id, -32601, "Method not found"); + } + + cJSON_Delete(request); + + /* Send the response. */ + if (response) { + char *resp_str = cJSON_PrintUnformatted(response); + if (resp_str) { + soup_server_message_set_status(msg, 200, NULL); + soup_server_message_set_response(msg, "application/json", + SOUP_MEMORY_TAKE, resp_str, strlen(resp_str)); + } + cJSON_Delete(response); + } else { + soup_server_message_set_status(msg, 500, NULL); + } +} + +/* ── Public API ───────────────────────────────────────────────────── */ + +void agent_mcp_register(SoupServer *server) { + soup_server_add_handler(server, "/mcp", on_mcp_request, NULL, NULL); + g_print("[agent] MCP endpoint: http://localhost:%d/mcp\n", + agent_server_get_port()); +} diff --git a/src/agent_mcp.h b/src/agent_mcp.h new file mode 100644 index 0000000..4b94098 --- /dev/null +++ b/src/agent_mcp.h @@ -0,0 +1,32 @@ +/* + * agent_mcp.h — MCP (Model Context Protocol) server endpoint + * + * Implements the MCP Streamable HTTP transport on top of our existing + * SoupServer. Exposes browser tools as MCP tools for AI assistants + * (Roo Code, Claude Code, Cursor, etc.). + * + * The endpoint is at http://localhost:PORT/mcp and accepts JSON-RPC + * POST requests. It uses the same agent_tools_dispatch() as the + * WebSocket endpoint — same code path, same tools. + */ + +#ifndef AGENT_MCP_H +#define AGENT_MCP_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Register the MCP handler at /mcp on the given SoupServer. + * Call this after soup_server_add_websocket_handler() in agent_server_start(). + */ +void agent_mcp_register(SoupServer *server); + +#ifdef __cplusplus +} +#endif + +#endif /* AGENT_MCP_H */ diff --git a/src/agent_server.c b/src/agent_server.c index 9cff6dc..e3292b6 100644 --- a/src/agent_server.c +++ b/src/agent_server.c @@ -8,6 +8,7 @@ */ #include "agent_server.h" +#include "agent_mcp.h" #include "settings.h" #include @@ -206,6 +207,9 @@ int agent_server_start(int port) { soup_server_add_websocket_handler(g_server, "/agent", NULL, NULL, on_websocket_handler, NULL, NULL); + /* Add MCP handler at /mcp. */ + agent_mcp_register(g_server); + /* Listen on the specified port (0 = auto-assign). */ soup_server_listen_local(g_server, port, 0, &error); if (error != NULL) { diff --git a/src/version.h b/src/version.h index 5a11027..d7d24f0 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.10" +#define SB_VERSION "v0.0.11" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 10 +#define SB_VERSION_PATCH 11 #endif /* SOVEREIGN_BROWSER_VERSION_H */