fdf8fb5cda
The sidecar that isolates all MCP wire complexity from the soul. Binds
loopback 127.0.0.1:7771 only. The soul reaches it over flat HTTP; the bridge
owns stdio/streamable-HTTP transports, OAuth (PKCE), Keychain secrets, server
lifecycle, config, and a tool-schema-hash poisoning guard.
HTTP contract: GET /mcp/tools, /mcp/servers, /mcp/auto-approved, /healthz;
POST /mcp/call, /mcp/oauth/start, /mcp/servers/{add,toggle,auto-approve,
remove,secret}; GET /mcp/oauth/callback.
Config: ~/.neuron/connectors.json (servers, no secrets). Secrets in macOS
Keychain (service ai.neuron.connect, account = serverId). Spec:
docs/research/mcp-connectors-adoption-spec.md.
Phases 1-3 verified end to end (stdio + HTTP transport, Keychain token auth,
OAuth round-trip); Phase 4/5 (CRUD + auto-approve + schema-hash) added for the
ConnectorsView UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
7.8 KiB
JavaScript
160 lines
7.8 KiB
JavaScript
import { createServer } from "node:http";
|
|
import { HOST, PORT } from "./config.js";
|
|
function sendJson(res, status, body) {
|
|
const payload = JSON.stringify(body);
|
|
res.writeHead(status, {
|
|
"content-type": "application/json",
|
|
"content-length": Buffer.byteLength(payload),
|
|
});
|
|
res.end(payload);
|
|
}
|
|
function sendHtml(res, status, html) {
|
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
res.end(html);
|
|
}
|
|
function authResultPage(ok, detail) {
|
|
const msg = ok ? "Connected. You can close this tab." : `Sign-in failed: ${detail}`;
|
|
return `<!doctype html><meta charset="utf-8"><title>Neuron Connectors</title>` +
|
|
`<body style="font:15px -apple-system,system-ui;margin:18% auto;max-width:420px;text-align:center;color:#222">` +
|
|
`<div style="font-size:42px">${ok ? "✓" : "✗"}</div><p>${msg}</p></body>`;
|
|
}
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
req.on("data", (c) => {
|
|
data += c;
|
|
if (data.length > 5_000_000)
|
|
reject(new Error("body too large"));
|
|
});
|
|
req.on("end", () => resolve(data));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
// Read + JSON-parse a request body; returns null on empty/invalid JSON.
|
|
async function parseJsonBody(req) {
|
|
const raw = await readBody(req);
|
|
try {
|
|
return JSON.parse(raw || "{}");
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
export function startServer(bridge) {
|
|
const server = createServer(async (req, res) => {
|
|
const url = req.url ?? "/";
|
|
const method = req.method ?? "GET";
|
|
try {
|
|
// GET /mcp/tools — merged, namespaced tool schemas for the soul.
|
|
if (method === "GET" && url === "/mcp/tools") {
|
|
return sendJson(res, 200, { tools: bridge.allTools() });
|
|
}
|
|
// GET /mcp/servers — per-server health for the UI.
|
|
if (method === "GET" && url === "/mcp/servers") {
|
|
return sendJson(res, 200, { servers: bridge.serverStatuses() });
|
|
}
|
|
// GET /healthz — liveness.
|
|
if (method === "GET" && url === "/healthz") {
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
// POST /mcp/oauth/start { id } — begin OAuth sign-in, return the auth URL.
|
|
if (method === "POST" && url === "/mcp/oauth/start") {
|
|
const raw = await readBody(req);
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw || "{}");
|
|
}
|
|
catch {
|
|
return sendJson(res, 400, { ok: false, error: "invalid JSON body" });
|
|
}
|
|
if (!parsed.id)
|
|
return sendJson(res, 400, { ok: false, error: "missing id" });
|
|
const result = await bridge.startOAuth(parsed.id);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// GET /mcp/oauth/callback?id=..&code=.. — provider redirects here after consent.
|
|
if (method === "GET" && url.startsWith("/mcp/oauth/callback")) {
|
|
const q = new URL(url, `http://${HOST}:${PORT}`).searchParams;
|
|
const id = q.get("id");
|
|
const code = q.get("code");
|
|
const err = q.get("error");
|
|
if (err)
|
|
return sendHtml(res, 400, authResultPage(false, err));
|
|
if (!id || !code)
|
|
return sendHtml(res, 400, authResultPage(false, "missing id or code"));
|
|
const result = await bridge.finishOAuth(id, code);
|
|
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
|
}
|
|
// GET /mcp/auto-approved — the soul reads this to decide which mcp__* calls skip the
|
|
// approval card (per-connector opt-in, off by default).
|
|
if (method === "GET" && url === "/mcp/auto-approved") {
|
|
return sendJson(res, 200, { tools: bridge.autoApprovedTools() });
|
|
}
|
|
// POST /mcp/servers/add { id, config } — add or replace a connector, connect it.
|
|
if (method === "POST" && url === "/mcp/servers/add") {
|
|
const parsed = await parseJsonBody(req);
|
|
if (!parsed || !parsed.id || !parsed.config)
|
|
return sendJson(res, 400, { ok: false, error: "missing id or config" });
|
|
const result = await bridge.addServer(parsed.id, parsed.config);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// POST /mcp/servers/toggle { id, enabled } — enable/disable a connector.
|
|
if (method === "POST" && url === "/mcp/servers/toggle") {
|
|
const parsed = await parseJsonBody(req);
|
|
if (!parsed || !parsed.id || typeof parsed.enabled !== "boolean")
|
|
return sendJson(res, 400, { ok: false, error: "missing id or enabled" });
|
|
const result = await bridge.setEnabled(parsed.id, parsed.enabled);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// POST /mcp/servers/auto-approve { id, autoApprove } — toggle approval-skip for a connector.
|
|
if (method === "POST" && url === "/mcp/servers/auto-approve") {
|
|
const parsed = await parseJsonBody(req);
|
|
if (!parsed || !parsed.id || typeof parsed.autoApprove !== "boolean")
|
|
return sendJson(res, 400, { ok: false, error: "missing id or autoApprove" });
|
|
const result = await bridge.setAutoApprove(parsed.id, parsed.autoApprove);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// POST /mcp/servers/secret { id, secret } — store an API token in the Keychain.
|
|
if (method === "POST" && url === "/mcp/servers/secret") {
|
|
const parsed = await parseJsonBody(req);
|
|
if (!parsed || !parsed.id || !parsed.secret)
|
|
return sendJson(res, 400, { ok: false, error: "missing id or secret" });
|
|
const result = await bridge.setSecret(parsed.id, parsed.secret);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// POST /mcp/servers/remove { id } — remove a connector + its stored secret.
|
|
if (method === "POST" && url === "/mcp/servers/remove") {
|
|
const parsed = await parseJsonBody(req);
|
|
if (!parsed || !parsed.id)
|
|
return sendJson(res, 400, { ok: false, error: "missing id" });
|
|
const result = await bridge.removeServer(parsed.id);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// POST /mcp/call { name, input } — proxy tools/call to the owning server.
|
|
if (method === "POST" && url === "/mcp/call") {
|
|
const raw = await readBody(req);
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw || "{}");
|
|
}
|
|
catch {
|
|
return sendJson(res, 400, { ok: false, error: "invalid JSON body" });
|
|
}
|
|
if (!parsed.name) {
|
|
return sendJson(res, 400, { ok: false, error: "missing tool name" });
|
|
}
|
|
const result = await bridge.call(parsed.name, parsed.input ?? {});
|
|
return sendJson(res, result.ok ? 200 : 502, result);
|
|
}
|
|
sendJson(res, 404, { ok: false, error: "not found" });
|
|
}
|
|
catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
sendJson(res, 500, { ok: false, error: msg });
|
|
}
|
|
});
|
|
server.listen(PORT, HOST, () => {
|
|
console.error(`[connectd] listening on http://${HOST}:${PORT}`);
|
|
});
|
|
}
|