Files
sovereign_browser/src/agent_mcp.c
T

929 lines
47 KiB
C

/*
* 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 "agent_tool_catalog.h"
#include "tab_manager.h"
#include "version.h"
#include <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
/* ── Session management ────────────────────────────────────────────── *
*
* MCP "Streamable HTTP" transport uses a Mcp-Session-Id header to
* correlate requests. The server generates a UUID on `initialize`
* and returns it in the response header. Subsequent requests should
* carry the same header. Sessions expire after 1 hour of inactivity
* (checked lazily on lookup).
*/
#define MCP_SESSION_TIMEOUT_SEC 3600 /* 1 hour */
typedef struct {
char *session_id;
gboolean initialized;
gint64 last_activity; /* time(NULL) */
} mcp_session_t;
/* session_id (owned) -> mcp_session_t* (owned) */
static GHashTable *g_sessions = NULL;
static mcp_session_t *session_create(void) {
mcp_session_t *s = g_new(mcp_session_t, 1);
s->session_id = g_uuid_string_random();
s->initialized = TRUE;
s->last_activity = time(NULL);
g_hash_table_insert(g_sessions, s->session_id, s);
return s;
}
static mcp_session_t *session_lookup(const char *id) {
if (id == NULL || g_sessions == NULL) {
return NULL;
}
mcp_session_t *s = g_hash_table_lookup(g_sessions, id);
if (s == NULL) {
return NULL;
}
/* Lazy expiry check. */
if (time(NULL) - s->last_activity > MCP_SESSION_TIMEOUT_SEC) {
g_hash_table_remove(g_sessions, id);
return NULL;
}
return s;
}
static void session_touch(mcp_session_t *s) {
if (s) {
s->last_activity = time(NULL);
}
}
static void session_destroy(mcp_session_t *s) {
if (s && g_sessions) {
g_hash_table_remove(g_sessions, s->session_id);
}
}
/* ── SSE helper ────────────────────────────────────────────────────── *
*
* Wraps a JSON-RPC response string in a single SSE event:
* event: message\r\n
* data: <json>\r\n
* \r\n
* Returns a newly-allocated string that the caller must free (or pass
* to soup_server_message_set_response with SOUP_MEMORY_TAKE).
*/
static char *build_sse_response(const char *json_str) {
if (json_str == NULL) {
json_str = "{}";
}
return g_strdup_printf("event: message\r\ndata: %s\r\n\r\n", json_str);
}
/* ── 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 ─────────────────────────────────────────────────── *
* Array of tool definitions. Each has a name, description, and JSON
* Schema for input parameters. The mcp_tool_def_t struct, the array,
* and build_tools_list() are declared in agent_tool_catalog.h so they
* can be shared with the embedded agent (agent_llm.c).
*/
const 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: 'random' (generate a fresh random key — quickest for testing), '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\":[\"random\",\"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 (including 'generate'). Frees the old signer first.",
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"generate\",\"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\":{}}"},
{"stop",
"Stop loading the current page. Mirrors the toolbar stop button.",
"{\"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\"]}"},
{"get_value",
"Get the value of an input, textarea, or select element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_count",
"Count the number of elements matching a CSS selector.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"get_box",
"Get the bounding box of an element by ref or CSS selector. Returns x, y, width, height, top, right, bottom, left.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_styles",
"Get the computed CSS styles of an element by ref or CSS selector. Returns all computed style properties.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_visible",
"Check if an element is visible (not display:none, visibility:hidden, opacity:0, or offsetParent null).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_enabled",
"Check if an element is enabled (not disabled).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_checked",
"Check if a checkbox or radio element is checked.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"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\"]}"},
{"screenshot",
"Capture a screenshot of the current page as a PNG image. Returns base64-encoded image data that can be viewed by the AI assistant. Use for visual context when the accessibility tree snapshot isn't sufficient.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Interaction tools */
{"click",
"Click an element by ref or CSS selector. Uses coordinate-based GDK event synthesis (real WebKit hit-testing) for SPA framework compatibility, with JS .click() fallback.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"click_at",
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
{"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\"]}"},
/* Extended interaction tools */
{"dblclick",
"Double-click an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"select",
"Select an option in a dropdown element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"check",
"Check a checkbox element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"uncheck",
"Uncheck a checkbox element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"scroll_into_view",
"Scroll an element into view by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"keyboard_type",
"Type text into the focused element with real keystroke events (keydown, keypress, keyup per character).",
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"insert_text",
"Insert text at the current cursor position without key events. Uses document.execCommand.",
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"keydown",
"Dispatch a keydown event for a key (hold key down).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"keyup",
"Dispatch a keyup event for a key (release key).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"drag",
"Drag an element and drop it onto another element (HTML5 drag events).",
"{\"type\":\"object\",\"properties\":{\"src_ref\":{\"type\":\"string\"},\"src_selector\":{\"type\":\"string\"},\"tgt_ref\":{\"type\":\"string\"},\"tgt_selector\":{\"type\":\"string\"}}}"},
{"close_all",
"Close all open tabs.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Find element tools */
{"find_role",
"Find an element by ARIA role (e.g. button, link, textbox, navigation). Optionally filter by name (aria-label or text content). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"role\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"}},\"required\":[\"role\"]}"},
{"find_text",
"Find an element by text content. Set exact=true for exact match. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"exact\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"text\"]}"},
{"find_label",
"Find an element by associated label (label[for], aria-label, or aria-labelledby). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\"}},\"required\":[\"label\"]}"},
{"find_placeholder",
"Find an element by placeholder text (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"placeholder\":{\"type\":\"string\"}},\"required\":[\"placeholder\"]}"},
{"find_alt",
"Find an element by alt text (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"alt\":{\"type\":\"string\"}},\"required\":[\"alt\"]}"},
{"find_title",
"Find an element by title attribute (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"}},\"required\":[\"title\"]}"},
{"find_testid",
"Find an element by data-testid attribute (exact match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"testid\":{\"type\":\"string\"}},\"required\":[\"testid\"]}"},
{"find_first",
"Find the first element matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"find_last",
"Find the last element matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"find_nth",
"Find the nth element (0-based) matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"n\":{\"type\":\"integer\"}},\"required\":[\"selector\",\"n\"]}"},
{"wait_for_text",
"Wait for specific text to appear on the page. Polls until the text is found in document.body.innerText or timeout.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"text\"]}"},
{"wait_for_url",
"Wait for the page URL to match a pattern. By default does a substring match; set regex=true for regex matching.",
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000},\"regex\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"url\"]}"},
{"wait_for_load",
"Wait for the page to finish loading (webkit_web_view_is_loading returns false).",
"{\"type\":\"object\",\"properties\":{\"timeout\":{\"type\":\"integer\",\"default\":10000}}}"},
{"wait_for_fn",
"Wait for a JavaScript expression to evaluate to truthy. The script is evaluated repeatedly until it returns true or timeout.",
"{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"script\"]}"},
{"batch",
"Execute multiple tool commands in sequence. Each command has 'tool', 'params', and optional 'id'. Set continueOnError=true to continue after failures.",
"{\"type\":\"object\",\"properties\":{\"commands\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"tool\":{\"type\":\"string\"},\"params\":{\"type\":\"object\"},\"id\":{\"type\":[\"integer\",\"string\"]}}}},\"continueOnError\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"commands\"]}"},
/* Cookies & web storage tools */
{"cookies_get",
"Get all cookies for the current page (non-httpOnly cookies visible to JavaScript).",
"{\"type\":\"object\",\"properties\":{}}"},
{"cookies_set",
"Set a cookie. Parameters: name, value, domain (optional), path (default /), secure, max_age (-1 for session cookie).",
"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"},\"domain\":{\"type\":\"string\"},\"path\":{\"type\":\"string\",\"default\":\"/\"},\"secure\":{\"type\":\"boolean\",\"default\":false},\"http_only\":{\"type\":\"boolean\",\"default\":false},\"max_age\":{\"type\":\"integer\",\"default\":-1}},\"required\":[\"name\",\"value\"]}"},
{"cookies_clear",
"Clear all cookies for the current page.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_local_get",
"Get all localStorage entries as a JSON object.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_local_get_key",
"Get a specific localStorage key value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"storage_local_set",
"Set a localStorage key to a value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
{"storage_local_clear",
"Clear all localStorage entries.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_session_get",
"Get all sessionStorage entries as a JSON object.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_session_get_key",
"Get a specific sessionStorage key value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"storage_session_set",
"Set a sessionStorage key to a value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
{"storage_session_clear",
"Clear all sessionStorage entries.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Mouse tools */
{"mouse_move",
"Move the mouse to the specified coordinates (clientX, clientY).",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}},\"required\":[\"x\",\"y\"]}"},
{"mouse_down",
"Press a mouse button at the specified coordinates. Button: left (default), middle, right.",
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
{"mouse_up",
"Release a mouse button at the specified coordinates. Button: left (default), middle, right.",
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
{"mouse_wheel",
"Scroll the mouse wheel by dy (vertical) and dx (horizontal) pixels.",
"{\"type\":\"object\",\"properties\":{\"dy\":{\"type\":\"integer\"},\"dx\":{\"type\":\"integer\",\"default\":0}},\"required\":[\"dy\"]}"},
/* Clipboard tools */
{"clipboard_read",
"Read text from the system clipboard.",
"{\"type\":\"object\",\"properties\":{}}"},
{"clipboard_write",
"Write text to the system clipboard.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"]}"},
{"clipboard_copy",
"Copy the current selection to clipboard (document.execCommand('copy')).",
"{\"type\":\"object\",\"properties\":{}}"},
{"clipboard_paste",
"Paste from clipboard into the focused element (document.execCommand('paste')).",
"{\"type\":\"object\",\"properties\":{}}"},
/* Settings tools */
{"set_viewport",
"Set the browser window/viewport size in pixels.",
"{\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"integer\"},\"height\":{\"type\":\"integer\"}},\"required\":[\"width\",\"height\"]}"},
{"set_offline",
"Toggle offline mode (not yet supported in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true}}}"},
{"set_headers",
"Set extra HTTP headers for all requests (not yet supported in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"headers\":{\"type\":\"object\"}}}"},
{"set_credentials",
"Set HTTP basic auth credentials (not yet supported — auth is interactive in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}},\"required\":[\"username\",\"password\"]}"},
{"set_media",
"Emulate color scheme (dark or light).",
"{\"type\":\"object\",\"properties\":{\"scheme\":{\"type\":\"string\",\"enum\":[\"dark\",\"light\"]}},\"required\":[\"scheme\"]}"},
/* Frame tools */
{"frame_switch",
"Switch to an iframe by ref or CSS selector. Subsequent JS-based tools will execute in the frame context.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"frame_main",
"Switch back to the main frame (exit any iframe context).",
"{\"type\":\"object\",\"properties\":{}}"},
/* Dialog tools */
{"dialog_accept",
"Accept a pending JavaScript dialog (alert, confirm, or prompt). For prompt dialogs, provide 'text' for the input value.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}"},
{"dialog_dismiss",
"Dismiss a pending JavaScript dialog (cancel).",
"{\"type\":\"object\",\"properties\":{}}"},
{"dialog_status",
"Check if a JavaScript dialog (alert/confirm/prompt) is pending. Returns the dialog type and message if pending.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Debug tools */
{"console",
"Get collected console messages from the page. Set clear=true to clear after reading.",
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
{"errors",
"Get JavaScript errors from the page. Set clear=true to clear after reading.",
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
{"highlight",
"Highlight an element with a temporary red outline. Useful for debugging.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"duration\":{\"type\":\"integer\",\"default\":2000}}}"},
/* State tools */
{"state_save",
"Save browser state (localStorage and cookies) to a file or return as JSON string.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}"},
{"state_load",
"Load browser state from a file or JSON string. Restores localStorage and cookies.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"state\":{\"type\":\"string\"}}}"},
/* Complex tools */
{"upload",
"Upload files to a file input element. Provide file paths on the local filesystem. Files are read, base64-encoded, and set on the input via DataTransfer API.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"files\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"files\"]}"},
{"pdf",
"Save the current page as a PDF file. Provide a file path for the output.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}"},
{"screenshot_annotated",
"Take a screenshot with element ref labels overlaid on the page. Combines snapshot and screenshot — returns both an image and the text accessibility tree.",
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true},\"compact\":{\"type\":\"boolean\",\"default\":true}}}"},
/* Filesystem & shell tools (work before login — system-level) */
{"fs_read",
"Read a file's text contents from the local filesystem. The browser runs in a dedicated qube, so full access is intended. Returns the file content as a string.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute or relative path to the file\"}},\"required\":[\"path\"]}"},
{"fs_write",
"Write text to a file on the local filesystem. Overwrites if the file exists, creates it (and parent directories) if it doesn't. Returns the number of bytes written.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file\"},\"content\":{\"type\":\"string\",\"description\":\"Text content to write\"}},\"required\":[\"path\",\"content\"]}"},
{"fs_list",
"List directory entries (files and subdirectories). Returns each entry's name, type (file/dir/link/other), and size in bytes.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory\"}},\"required\":[\"path\"]}"},
{"fs_mkdir",
"Create a directory, including parent directories if needed (recursive, like mkdir -p).",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory to create\"}},\"required\":[\"path\"]}"},
{"fs_delete",
"Delete a file or an empty directory. Non-empty directories must be removed with shell_exec (e.g. rm -rf).",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file or empty directory\"}},\"required\":[\"path\"]}"},
{"shell_exec",
"Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.",
"{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"},
};
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));
/* ── Build tools/list response ────────────────────────────────────── */
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;
}
const char *method = soup_server_message_get_method(msg);
/* ── GET: open an SSE stream for server→client push events ──── */
if (g_strcmp0(method, "GET") == 0) {
/* Pragmatic approach: return a valid SSE content-type with an
* initial connection comment. A truly long-lived push stream
* can be added later; for now this satisfies clients that
* probe GET /mcp and expect text/event-stream. */
soup_server_message_set_status(msg, 200, NULL);
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
const char *sse_init = ": connected\r\n\r\n";
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_STATIC, sse_init, strlen(sse_init));
return;
}
/* ── DELETE: terminate a session ─────────────────────────────── */
if (g_strcmp0(method, "DELETE") == 0) {
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
if (sid == NULL) {
soup_server_message_set_status(msg, 400, NULL);
return;
}
mcp_session_t *s = session_lookup(sid);
if (s == NULL) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
session_destroy(s);
soup_server_message_set_status(msg, 200, NULL);
return;
}
/* Only POST is handled below. */
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\"}}";
char *sse = build_sse_response(err);
soup_server_message_set_status(msg, 200, NULL);
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_TAKE, sse, strlen(sse));
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;
/* ── Session validation ──────────────────────────────────────── *
* For `initialize` we create a new session. For all other methods,
* we check the Mcp-Session-Id header. If the header is present but
* the session is unknown/expired, return 404. If the header is
* absent, we proceed (lenient) for backward compatibility. */
mcp_session_t *session = NULL;
gboolean is_initialize = (rpc_method && strcmp(rpc_method, "initialize") == 0);
if (!is_initialize) {
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
if (sid != NULL) {
session = session_lookup(sid);
if (session == NULL) {
/* Unknown or expired session — create a new one and return
* the new session ID in the response header so the client
* can update its cached session ID. This handles browser
* restarts gracefully without requiring the client to
* re-initialize. */
session = session_create();
SoupMessageHeaders *resp_hdrs =
soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs,
"Mcp-Session-Id", session->session_id);
g_print("[mcp] Stale session '%s' — created new session '%s'\n",
sid, session->session_id);
}
session_touch(session);
} else {
g_warning("[mcp] Request without Mcp-Session-Id header (method=%s) — proceeding leniently",
rpc_method ? rpc_method : "?");
}
}
cJSON *response = NULL;
if (rpc_method == NULL) {
response = rpc_error(rpc_id, -32600, "Invalid Request");
} else if (is_initialize) {
/* Create a new session and return its ID in the response header. */
session = session_create();
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Mcp-Session-Id", session->session_id);
/* 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}
*
* For the screenshot tool, the response contains a
* data.screenshot field with base64 PNG data. In that
* case we emit an image content block per the MCP spec
* instead of a text block. */
cJSON *result = cJSON_CreateObject();
cJSON *content = cJSON_CreateArray();
cJSON *data_obj = cJSON_GetObjectItem(tool_response, "data");
cJSON *screenshot = data_obj ? cJSON_GetObjectItem(data_obj, "screenshot") : NULL;
if (screenshot && cJSON_IsString(screenshot)) {
/* Image content block per MCP spec. */
cJSON *img_item = cJSON_CreateObject();
cJSON_AddStringToObject(img_item, "type", "image");
cJSON_AddStringToObject(img_item, "data", screenshot->valuestring);
cJSON_AddStringToObject(img_item, "mimeType", "image/png");
cJSON_AddItemToArray(content, img_item);
/* If the response also includes a snapshot text tree
* (e.g. from screenshot_annotated), emit it as an
* additional text content block. */
cJSON *snapshot_text = cJSON_GetObjectItem(data_obj, "snapshot");
if (snapshot_text && cJSON_IsString(snapshot_text)) {
cJSON *text_item = cJSON_CreateObject();
cJSON_AddStringToObject(text_item, "type", "text");
cJSON_AddStringToObject(text_item, "text", snapshot_text->valuestring);
cJSON_AddItemToArray(content, text_item);
}
} else {
/* Default: text content block with the JSON response. */
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);
free(resp_str);
}
cJSON_AddItemToObject(result, "content", content);
gboolean is_error = !cJSON_IsTrue(cJSON_GetObjectItem(tool_response, "success"));
cJSON_AddBoolToObject(result, "isError", is_error);
cJSON_Delete(tool_response);
response = rpc_result(rpc_id, result);
}
}
} else if (strcmp(rpc_method, "ping") == 0) {
/* MCP ping — health check. Return an empty result. */
response = rpc_result(rpc_id, cJSON_CreateObject());
} else if (strcmp(rpc_method, "resources/list") == 0) {
/* We don't expose resources — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "resources", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "resources/templates/list") == 0) {
/* No resource templates — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "resourceTemplates", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "prompts/list") == 0) {
/* We don't expose prompts — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "prompts", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "logging/setLevel") == 0) {
/* Accept any log level — we don't filter, but acknowledge. */
response = rpc_result(rpc_id, cJSON_CreateObject());
} else if (g_str_has_prefix(rpc_method, "notifications/")) {
/* MCP notifications (initialized, cancelled, progress, etc.)
* have no id and expect no response body. Return 202 Accepted. */
cJSON_Delete(request);
soup_server_message_set_status(msg, 202, NULL);
return;
} else if (id_json == NULL) {
/* Any request without an id is a notification per JSON-RPC spec.
* Return 202 Accepted with no body. */
cJSON_Delete(request);
soup_server_message_set_status(msg, 202, NULL);
return;
} else {
response = rpc_error(rpc_id, -32601, "Method not found");
}
cJSON_Delete(request);
/* Send the response as SSE (Server-Sent Events).
* Notifications (no id) already returned 202 above and don't reach
* here. All JSON-RPC responses with an id are wrapped in a single
* SSE event: "event: message\r\ndata: <json>\r\n\r\n" */
if (response) {
char *resp_str = cJSON_PrintUnformatted(response);
cJSON_Delete(response);
if (resp_str) {
char *sse = build_sse_response(resp_str);
free(resp_str);
soup_server_message_set_status(msg, 200, NULL);
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_TAKE, sse, strlen(sse));
} else {
soup_server_message_set_status(msg, 500, NULL);
}
} else {
soup_server_message_set_status(msg, 500, NULL);
}
}
/* ── Public API ───────────────────────────────────────────────────── */
void agent_mcp_register(SoupServer *server) {
/* Initialize the session table if not yet created. */
if (g_sessions == NULL) {
g_sessions = g_hash_table_new_full(g_str_hash, g_str_equal,
g_free, g_free);
}
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());
}