feat(soul): MCP connectors — /api/connectors proxy + per-connector auto-approve

Adds the soul side of the connectors feature (spec: docs/research/
mcp-connectors-adoption-spec.md). The soul thin-proxies the neuron-connectd
bridge on 127.0.0.1:7771 so the UI talks to one origin and never reaches the
bridge directly.

routes.el:
- handle_connectors + connectd_get/connectd_post helpers (POST bodies go via
  a temp file + curl -d @file, so model/UI input can't reach the shell).
- GET /api/connectors and POST /api/connectors/{add,toggle,auto-approve,
  remove,secret,oauth/start} registered in both GET and POST routers.

chat.el:
- tool_auto_approved(): an mcp__* tool skips the approval card only when its
  server is explicitly opted in (off by default; built-in tools unaffected;
  bridge down -> false). Wired into the agentic approval gate so an
  auto-approved connector tool flows straight to execution.

Regenerated dist/chat.c and dist/routes.c. Verified live on :7770: real chat,
recall, and /api/connectors all work after promotion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tim Lingo
2026-06-13 18:43:14 -05:00
parent 8eea1d94ff
commit 6c57d4fe1b
4 changed files with 1079 additions and 314 deletions
+89 -4
View File
@@ -13,6 +13,15 @@ fn strip_query(path: String) -> String {
return str_slice(path, 0, q)
}
// Truthy flag test tolerant of BOTH the boolean form (Kotlin UI sends true) and
// the integer form (el-src UI sends 1). json_get_bool only recognizes literal
// `true`, so without this an "agentic":1 request silently routes to the
// non-agentic, tool-less path which is exactly how the UI ended up with no
// tools.
fn flag_true(body: String, key: String) -> Bool {
return json_get_bool(body, key) || json_get_int(body, key) > 0
}
fn err_404(path: String) -> String {
return "{\"error\":\"not found\",\"path\":\"" + path + "\"}"
}
@@ -142,13 +151,18 @@ fn handle_dharma_recv(body: String) -> String {
} else {
eff_payload
}
let agentic_flag: Bool = json_get_bool(eff_payload, "agentic")
let agentic_flag: Bool = flag_true(eff_payload, "agentic")
let reply: String = if agentic_flag {
handle_chat_agentic(chat_body)
} else {
handle_chat(chat_body)
}
auto_persist(chat_body, reply)
// A paused tool loop is not a completed exchange the approve
// handler persists once the loop finishes.
let is_pending: Bool = str_contains(reply, "\"status\":\"tool_pending\"")
if !is_pending {
auto_persist(chat_body, reply)
}
return reply
}
@@ -203,6 +217,58 @@ fn route_sessions() -> String {
return results
}
// Connectors API (Phase 4)
// Thin proxy from the UI to the neuron-connectd bridge on 127.0.0.1:7771, so the
// app talks to ONE origin (the soul) and never reaches the bridge directly. The
// bridge owns all MCP/config complexity; these handlers just forward + relay.
fn connectd_get(suffix: String) -> String {
let out: String = exec_capture("curl -s --max-time 5 http://127.0.0.1:7771" + suffix)
if str_eq(out, "") {
return "{\"ok\":false,\"error\":\"connector bridge unreachable (neuron-connectd on :7771)\"}"
}
return out
}
// POST passthrough: the request body is written to a temp file and handed to curl
// via -d @file, so arbitrary JSON can never reach the shell as an argument.
fn connectd_post(suffix: String, body: String) -> String {
let eff: String = if str_eq(body, "") { "{}" } else { body }
let tmp: String = "/tmp/neuron-connectors-req.json"
fs_write(tmp, eff)
let out: String = exec_capture("curl -s --max-time 20 -X POST http://127.0.0.1:7771" + suffix + " -H 'Content-Type: application/json' -d @" + tmp)
if str_eq(out, "") {
return "{\"ok\":false,\"error\":\"connector bridge unreachable (neuron-connectd on :7771)\"}"
}
return out
}
fn handle_connectors(method: String, clean: String, body: String) -> String {
if str_eq(method, "GET") {
// /api/connectors -> each configured server with status, tools, auth, auto-approve.
return connectd_get("/mcp/servers")
}
if str_eq(clean, "/api/connectors/add") {
return connectd_post("/mcp/servers/add", body)
}
if str_eq(clean, "/api/connectors/toggle") {
return connectd_post("/mcp/servers/toggle", body)
}
if str_eq(clean, "/api/connectors/auto-approve") {
return connectd_post("/mcp/servers/auto-approve", body)
}
if str_eq(clean, "/api/connectors/remove") {
return connectd_post("/mcp/servers/remove", body)
}
if str_eq(clean, "/api/connectors/secret") {
return connectd_post("/mcp/servers/secret", body)
}
if str_eq(clean, "/api/connectors/oauth/start") {
return connectd_post("/mcp/oauth/start", body)
}
return "{\"ok\":false,\"error\":\"unknown connectors route\"}"
}
fn handle_request(method: String, path: String, body: String) -> String {
let clean: String = strip_query(path)
@@ -301,6 +367,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
if str_starts_with(clean, "/api/neuron/recall") {
return handle_api_recall(method, path, body)
}
if str_starts_with(clean, "/api/connectors") {
return handle_connectors(method, clean, body)
}
return err_404(clean)
}
@@ -318,15 +387,28 @@ fn handle_request(method: String, path: String, body: String) -> String {
return handle_elp_chat(body)
}
if str_eq(clean, "/api/chat") {
let agentic_flag: Bool = json_get_bool(body, "agentic")
let agentic_flag: Bool = flag_true(body, "agentic")
let reply: String = if agentic_flag {
handle_chat_agentic(body)
} else {
handle_chat(body)
}
auto_persist(body, reply)
// A paused tool loop is not a completed exchange the approve
// handler persists once the loop finishes.
let is_pending: Bool = str_contains(reply, "\"status\":\"tool_pending\"")
if !is_pending {
auto_persist(body, reply)
}
return reply
}
// The desktop UI's tool-approval flow: resume a parked agentic loop
// with the user's allow/deny decision on the pending tool call.
if str_starts_with(clean, "/api/sessions/") && str_ends_with(clean, "/approve") {
let sid_start: Int = str_len("/api/sessions/")
let sid_end: Int = str_len(clean) - str_len("/approve")
let sid: String = str_slice(clean, sid_start, sid_end)
return handle_session_approve(sid, body)
}
if str_eq(clean, "/api/see") {
return handle_see(body)
}
@@ -363,6 +445,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
if str_starts_with(clean, "/api/imprints") {
return axon_post(clean, body)
}
if str_starts_with(clean, "/api/connectors") {
return handle_connectors(method, clean, body)
}
// Neuron cognitive API POST endpoints
if str_eq(clean, "/api/neuron/session/begin") {
return handle_api_begin_session(body)