929 lines
47 KiB
C
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());
|
|
}
|