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.

This commit is contained in:
Laan Tungir
2026-07-12 06:27:33 -04:00
parent 3c9be0da01
commit c6495beb6d
7 changed files with 738 additions and 4 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -1 +1 @@
0.0.10
0.0.11
+315
View File
@@ -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
+383
View File
@@ -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 <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
/* ── 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());
}
+32
View File
@@ -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 <libsoup/soup.h>
#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 */
+4
View File
@@ -8,6 +8,7 @@
*/
#include "agent_server.h"
#include "agent_mcp.h"
#include "settings.h"
#include <libsoup/soup.h>
@@ -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) {
+2 -2
View File
@@ -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 */