Merge PR #14: feat(soul): MCP connectors — /api/connectors proxy + per-connector auto-approve
Applies connector-specific additions from feat/connectors-soul: - chat.el: connector_tools_json(), agentic_tools_all(), call_mcp_bridge(), tool_auto_approved() and mcp__ dispatch in dispatch_tool() - routes.el: connectd_get/post, handle_connectors(), /api/connectors routing in GET and POST sections - MEMORY_RECALL_BUG.md: investigation notes on memory retrieval failure The agentic loop rewrite in the source branch was not applied — it conflicts with the tool-bridge pattern from PR #5 which is the chosen design for client-side MCP tool execution. The connectors themselves are now fully wired: connector tools surface as mcp__<server>__<tool> in the tools array and dispatch to neuron-connectd via call_mcp_bridge().
This commit is contained in:
@@ -270,6 +270,70 @@ fn agentic_tools_with_web() -> String {
|
||||
return "[" + inner + ",{\"type\":\"web_search_20250305\",\"name\":\"web_search\",\"max_uses\":5}]"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP connectors. The soul consumes external MCP tools through neuron-connectd,
|
||||
// the loopback bridge (Accessor) on 127.0.0.1:7771. The bridge isolates all MCP
|
||||
// wire complexity (stdio framing, SSE, OAuth, server lifecycle); the soul only
|
||||
// speaks flat HTTP. Spec: docs/research/mcp-connectors-adoption-spec.md.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fetch the merged, namespaced tool schemas (mcp__<srv>__<tool>) from the bridge.
|
||||
// Short timeout + empty-array fallback: if the bridge is down, the soul runs
|
||||
// exactly as before with only its built-in tools (graceful degradation).
|
||||
fn connector_tools_json() -> String {
|
||||
let raw: String = exec_capture("curl -s --max-time 2 http://127.0.0.1:7771/mcp/tools")
|
||||
if str_eq(raw, "") {
|
||||
return "[]"
|
||||
}
|
||||
let arr: String = json_get_raw(raw, "tools")
|
||||
if str_eq(arr, "") {
|
||||
return "[]"
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// Built-in tools + native web_search + every connector tool, as one tools array.
|
||||
// Splices connector tools in before the closing bracket of the base array.
|
||||
fn agentic_tools_all() -> String {
|
||||
let base: String = agentic_tools_with_web()
|
||||
let conn: String = connector_tools_json()
|
||||
let conn_inner: String = str_slice(conn, 1, str_len(conn) - 1)
|
||||
if str_eq(conn_inner, "") {
|
||||
return base
|
||||
}
|
||||
let base_open: String = str_slice(base, 0, str_len(base) - 1)
|
||||
return base_open + "," + conn_inner + "]"
|
||||
}
|
||||
|
||||
// Proxy one tool call to the bridge. The model-supplied input is written to a
|
||||
// temp file and handed to curl via -d @file, so arbitrary JSON can never reach
|
||||
// the shell as an argument (no injection through tool_input).
|
||||
fn call_mcp_bridge(tool_name: String, tool_input: String) -> String {
|
||||
let eff_input: String = if str_eq(tool_input, "") { "{}" } else { tool_input }
|
||||
let body: String = "{\"name\":\"" + tool_name + "\",\"input\":" + eff_input + "}"
|
||||
let tmp: String = "/tmp/neuron-mcp-call.json"
|
||||
fs_write(tmp, body)
|
||||
return exec_capture("curl -s --max-time 30 -X POST http://127.0.0.1:7771/mcp/call -H 'Content-Type: application/json' -d @" + tmp)
|
||||
}
|
||||
|
||||
// Per-connector auto-approve: true only for an mcp__* tool whose server the user has
|
||||
// explicitly opted into skipping the approval card (off by default). Built-in tools are
|
||||
// never auto-approved here — they keep their existing gating. Bridge down → false (safe).
|
||||
fn tool_auto_approved(tool_name: String) -> Bool {
|
||||
if !str_starts_with(tool_name, "mcp__") {
|
||||
return false
|
||||
}
|
||||
let raw: String = exec_capture("curl -s --max-time 2 http://127.0.0.1:7771/mcp/auto-approved")
|
||||
if str_eq(raw, "") {
|
||||
return false
|
||||
}
|
||||
let list: String = json_get_raw(raw, "tools")
|
||||
if str_eq(list, "") {
|
||||
return false
|
||||
}
|
||||
return str_contains(list, "\"" + tool_name + "\"")
|
||||
}
|
||||
|
||||
fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
if str_eq(tool_name, "read_file") {
|
||||
let path: String = json_get(tool_input, "path")
|
||||
@@ -297,6 +361,21 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
let result: String = exec_capture(cmd)
|
||||
return json_safe(result)
|
||||
}
|
||||
// MCP connector tools (namespaced mcp__<server>__<tool>) are routed through
|
||||
// neuron-connectd. The bridge handles all MCP wire protocol complexity.
|
||||
if str_starts_with(tool_name, "mcp__") {
|
||||
let out: String = call_mcp_bridge(tool_name, tool_input)
|
||||
if str_eq(out, "") {
|
||||
return json_safe("MCP bridge unreachable (neuron-connectd on :7771)")
|
||||
}
|
||||
let content: String = json_get(out, "content")
|
||||
if str_eq(content, "") {
|
||||
let err: String = json_get(out, "error")
|
||||
let msg: String = if str_eq(err, "") { "MCP call failed" } else { "MCP error: " + err }
|
||||
return json_safe(msg)
|
||||
}
|
||||
return json_safe(content)
|
||||
}
|
||||
return "unknown tool: " + tool_name
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user