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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user