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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user