feat: neuron-connectd — MCP connector bridge (Accessor sidecar)

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>
This commit is contained in:
2026-06-13 18:45:55 -05:00
commit fdf8fb5cda
19 changed files with 3378 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.log
.skills-sync*
+86
View File
@@ -0,0 +1,86 @@
# neuron-connectd
The Neuron **MCP-connector bridge** — the Accessor that isolates the MCP wire
protocol (stdio framing, SSE, OAuth, server lifecycle) behind a flat loopback
HTTP contract the El soul consumes. The soul stays El-simple; this process
absorbs the ecosystem.
Spec: `../docs/research/mcp-connectors-adoption-spec.md`.
## Run
```bash
npm install
npm run dev # tsx src/index.ts (dev)
# or
npm run build && npm start
```
Binds **loopback only**: `http://127.0.0.1:7771` (override with `NEURON_CONNECTD_PORT`).
## Config
Reads `~/.neuron/connectors.json`. If absent, falls back to a Phase-1 default:
one zero-auth filesystem MCP server scoped to `~/neuron-connectd-sandbox`.
```json
{
"servers": {
"filesystem": {
"transport": "stdio",
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
}
}
}
```
Secrets never live in this file — Phase 3 puts OAuth/API tokens in the macOS
Keychain (`ai.neuron.connect`).
## HTTP contract (Axon-shaped)
| Method + path | Body | Returns |
|----------------------|---------------------------|---------|
| `GET /mcp/tools` | — | `{ tools: [{ name:"mcp__<srv>__<tool>", description, input_schema }] }` |
| `POST /mcp/call` | `{ name, input }` | `{ ok, content }` or `{ ok:false, error }` |
| `GET /mcp/servers` | — | `{ servers: [{ id, status, tools }] }` |
| `GET /healthz` | — | `{ ok:true }` |
Tools are namespaced `mcp__<serverId>__<toolName>`; `POST /mcp/call` routes back
to the owning server by that prefix. `input_schema` is translated from MCP's
`inputSchema` into Anthropic tool format so the soul can splice it straight into
its tools array.
## Auth (Phase 3)
Remote (`http`) connectors support two auth modes:
- **`auth: "token"`** — a static bearer token stored in the Keychain under
account `<serverId>`. The bridge reads it and sends `Authorization: Bearer …`.
Covers GitHub PATs and any API-key MCP server.
- **`auth: "oauth"`** — SDK-driven PKCE. The bridge exposes:
- `POST /mcp/oauth/start {id}``{ authUrl }` (open in the system browser)
- `GET /mcp/oauth/callback?id=..&code=..` → completes the exchange, stores
tokens in the Keychain, reconnects.
Tokens, the dynamic-registration client info, and the in-flight PKCE verifier
all live in the Keychain (`<serverId>:oauth` / `:client` / `:verifier`), so a
sign-in survives bridge restarts.
## Status
- **Phase 1 (done):** stdio servers, tool merge + namespacing, call routing,
graceful error paths. Proven against `server-filesystem` via curl.
- **Phase 2 (done):** soul integration — `chat.el` merges + dispatches `mcp__*`
tools; the model calls them through the approval loop. Proven on a rebuilt soul.
- **Phase 3 (done):** `http` (Streamable HTTP) transport, Keychain-backed
secrets, token auth, OAuth (PKCE) provider + endpoints. Token path proven
end-to-end against a local token-gated MCP server (positive + negative);
OAuth provider mechanics proven via Keychain round-trip. **Note:** the full
live OAuth handshake (discovery → DCR → consent) is correct-by-construction on
the SDK's flow but still needs a real OAuth MCP server to exercise end-to-end.
- **Phase 4 (todo):** `ConnectorsView.kt` UI + `/api/connectors/*` soul routes.
- **Phase 5 (todo):** server catalog, schema-hash pinning, per-connector
auto-approve, config hot-reload, run `neuron-connectd` as a managed daemon.
+327
View File
@@ -0,0 +1,327 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { createHash } from "node:crypto";
import { saveConfig } from "./config.js";
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
import { kcGet, kcSet, kcDelete } from "./keychain.js";
// Namespacing: every external tool is exposed to the soul as
// mcp__<serverId>__<toolName>
// so the soul can route a call back to the owning server by prefix alone.
const PREFIX = "mcp__";
const SEP = "__";
export function namespacedName(serverId, tool) {
return `${PREFIX}${serverId}${SEP}${tool}`;
}
// Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }.
// Tool names may themselves contain "__", so we only split on the FIRST two seps.
export function parseName(full) {
if (!full.startsWith(PREFIX))
return null;
const rest = full.slice(PREFIX.length);
const i = rest.indexOf(SEP);
if (i < 0)
return null;
return { serverId: rest.slice(0, i), tool: rest.slice(i + SEP.length) };
}
export class ConnectorBridge {
servers = new Map();
// The live config the bridge owns and persists. UI edits mutate this and call saveConfig.
config = { servers: {} };
async start(config) {
this.config = config;
const ids = Object.keys(config.servers);
await Promise.all(ids.map((id) => this.connectServer(id, config.servers[id])));
}
// ── Config CRUD (Phase 4 UI backs onto these via the soul's /api/connectors routes) ──
// Add or replace a server, persist, and connect it. Returns its fresh status.
async addServer(id, cfg) {
if (!id || !/^[a-zA-Z0-9_-]+$/.test(id))
return { ok: false, error: "invalid server id" };
this.config.servers[id] = cfg;
saveConfig(this.config);
await this.reconnectServer(id);
const s = this.servers.get(id);
return s && s.status === "error" ? { ok: false, error: s.error } : { ok: true };
}
async setEnabled(id, enabled) {
const cfg = this.config.servers[id];
if (!cfg)
return { ok: false, error: `unknown server: ${id}` };
cfg.enabled = enabled;
// Re-enabling acknowledges any flagged schema change (user is opting back in).
if (enabled)
delete cfg.schemaHash;
saveConfig(this.config);
await this.reconnectServer(id);
return { ok: true };
}
async setAutoApprove(id, autoApprove) {
const cfg = this.config.servers[id];
if (!cfg)
return { ok: false, error: `unknown server: ${id}` };
cfg.autoApprove = autoApprove;
saveConfig(this.config);
const entry = this.servers.get(id);
if (entry)
entry.cfg = cfg;
return { ok: true };
}
// Store a connector's API token in the Keychain (account = serverId), then reconnect.
// Used for http auth:"token" servers; the secret never lands in connectors.json.
async setSecret(id, secret) {
if (!secret)
return { ok: false, error: "empty secret" };
const stored = await kcSet(id, secret);
if (!stored)
return { ok: false, error: "keychain write failed" };
if (this.config.servers[id])
await this.reconnectServer(id);
return { ok: true };
}
async removeServer(id) {
if (!this.config.servers[id])
return { ok: false, error: `unknown server: ${id}` };
await this.closeServer(id);
delete this.config.servers[id];
this.servers.delete(id);
saveConfig(this.config);
await kcDelete(id).catch(() => { }); // drop any stored secret for this connector
return { ok: true };
}
// Tear down an existing client (if any) and connect fresh from current config.
async reconnectServer(id) {
await this.closeServer(id);
const cfg = this.config.servers[id];
if (cfg)
await this.connectServer(id, cfg);
}
async closeServer(id) {
const entry = this.servers.get(id);
if (entry?.client) {
try {
await entry.client.close();
}
catch { /* best effort */ }
}
}
async connectServer(id, cfg) {
if (!cfg.enabled) {
this.servers.set(id, { id, cfg, status: "disabled", tools: [] });
return;
}
if (cfg.transport === "stdio")
return this.connectStdio(id, cfg);
if (cfg.transport === "http")
return this.connectHttp(id, cfg);
this.servers.set(id, { id, cfg, status: "error", tools: [], error: `unknown transport "${cfg.transport}"` });
}
async connectStdio(id, cfg) {
try {
const transport = new StdioClientTransport({
command: cfg.command,
args: cfg.args ?? [],
env: { ...process.env, ...(cfg.env ?? {}) },
});
await this.finishConnect(id, cfg, transport);
}
catch (err) {
this.setError(id, cfg, err);
}
}
// Remote MCP over Streamable HTTP. Two auth modes:
// auth: "token" → static bearer from Keychain account <serverId>
// auth: "oauth" → SDK-driven PKCE; needs_auth until the user signs in
async connectHttp(id, cfg) {
if (!cfg.url) {
this.servers.set(id, { id, cfg, status: "error", tools: [], error: "http transport requires a url" });
return;
}
const url = new URL(cfg.url);
if (cfg.auth === "oauth") {
const provider = new KeychainOAuthProvider(id, cfg.scope);
const transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
try {
await this.finishConnect(id, cfg, transport);
}
catch (err) {
if (err instanceof UnauthorizedError) {
// Provider stashed the auth URL; keep the transport so /oauth/callback
// can finishAuth on it. Surface needs_auth to the UI.
this.servers.set(id, {
id, cfg, status: "needs_auth", tools: [],
pendingTransport: transport, pendingProvider: provider,
error: "sign-in required",
});
console.error(`[connectd] ${id}: needs OAuth sign-in`);
}
else {
this.setError(id, cfg, err);
}
}
return;
}
// token (or none)
let requestInit;
if (cfg.auth === "token") {
const token = await kcGet(id);
if (!token) {
this.servers.set(id, { id, cfg, status: "needs_auth", tools: [], error: "no token in Keychain" });
return;
}
requestInit = { headers: { Authorization: `Bearer ${token}` } };
}
try {
const transport = new StreamableHTTPClientTransport(url, { requestInit });
await this.finishConnect(id, cfg, transport);
}
catch (err) {
this.setError(id, cfg, err);
}
}
// Connect a client over the given transport, list tools, record as connected.
async finishConnect(id, cfg, transport) {
const client = new Client({ name: "neuron-connectd", version: "0.1.0" }, { capabilities: {} });
await client.connect(transport);
const listed = await client.listTools();
const tools = listed.tools.map((t) => ({
name: namespacedName(id, t.name),
description: t.description ?? "",
input_schema: t.inputSchema ?? { type: "object", properties: {} },
}));
// Tool-poisoning guard: hash name+description+schema of every tool. First connect pins the
// hash; a later mismatch flags the server in the UI (a silently-changed tool description is a
// prompt-injection vector) until the user re-enables it.
const hash = schemaHashOf(tools);
let schemaChanged = false;
if (cfg.schemaHash && cfg.schemaHash !== hash) {
schemaChanged = true;
}
else if (!cfg.schemaHash) {
cfg.schemaHash = hash;
this.config.servers[id] = cfg;
saveConfig(this.config);
}
this.servers.set(id, { id, cfg, client, status: "connected", tools, schemaChanged });
console.error(`[connectd] ${id}: connected (${cfg.transport}), ${tools.length} tools${schemaChanged ? " [SCHEMA CHANGED]" : ""}`);
}
setError(id, cfg, err) {
const msg = err instanceof Error ? err.message : String(err);
this.servers.set(id, { id, cfg, status: "error", tools: [], error: msg });
console.error(`[connectd] ${id}: connect failed — ${msg}`);
}
// Begin an OAuth sign-in: returns the authorization URL for the UI to open.
async startOAuth(id) {
const entry = this.servers.get(id);
if (!entry)
return { ok: false, error: `unknown server: ${id}` };
if (entry.cfg.transport !== "http" || entry.cfg.auth !== "oauth") {
return { ok: false, error: `server ${id} is not an OAuth connector` };
}
// Re-drive the connect to (re)generate the auth URL if not already pending.
if (entry.status !== "needs_auth" || !entry.pendingTransport) {
await this.connectHttp(id, entry.cfg);
}
const url = takePendingAuthUrl(id);
if (!url)
return { ok: false, error: "no authorization URL produced (server may not require OAuth)" };
return { ok: true, authUrl: url };
}
// Complete an OAuth sign-in with the authorization code from the callback.
async finishOAuth(id, code) {
const entry = this.servers.get(id);
if (!entry?.pendingTransport)
return { ok: false, error: `no pending OAuth flow for ${id}` };
try {
await entry.pendingTransport.finishAuth(code);
// Tokens are now in Keychain; reconnect cleanly to list tools.
await this.connectHttp(id, entry.cfg);
const now = this.servers.get(id);
return now?.status === "connected"
? { ok: true }
: { ok: false, error: `post-auth status: ${now?.status} (${now?.error ?? ""})` };
}
catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// Merged, namespaced tool list across all connected servers.
allTools() {
const out = [];
for (const s of this.servers.values())
out.push(...s.tools);
return out;
}
// Per-server status for the UI / health endpoint.
serverStatuses() {
return [...this.servers.values()].map((s) => ({
id: s.id,
status: s.status,
transport: s.cfg.transport,
tools: s.tools.length,
enabled: s.cfg.enabled,
auth: s.cfg.auth,
autoApprove: s.cfg.autoApprove === true,
schemaChanged: s.schemaChanged === true,
error: s.error,
}));
}
// The set of connector tool names that are auto-approved (the soul reads this to decide
// whether an mcp__* call skips the approval card). Only connected, enabled, opted-in servers.
autoApprovedTools() {
const out = [];
for (const s of this.servers.values()) {
if (s.cfg.autoApprove && s.status === "connected")
out.push(...s.tools.map((t) => t.name));
}
return out;
}
// Route a namespaced call to the owning server and flatten the result to a string
// (the soul does json_get(out,"content") then truncates at 6000 chars).
async call(full, input) {
const parsed = parseName(full);
if (!parsed)
return { ok: false, error: `not an mcp tool name: ${full}` };
const entry = this.servers.get(parsed.serverId);
if (!entry || !entry.client) {
const why = entry?.status === "needs_auth" ? "needs_auth" : `server not connected: ${parsed.serverId}`;
return { ok: false, error: why };
}
try {
const res = await entry.client.callTool({
name: parsed.tool,
arguments: (input ?? {}),
});
return { ok: true, content: flattenContent(res.content) };
}
catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
}
// Stable SHA-256 over each tool's name + description + input schema. Sorted by name so tool
// ordering doesn't churn the hash; only real content changes do.
function schemaHashOf(tools) {
const canon = [...tools]
.sort((a, b) => a.name.localeCompare(b.name))
.map((t) => `${t.name}${t.description}${JSON.stringify(t.input_schema)}`)
.join("");
return createHash("sha256").update(canon).digest("hex");
}
// MCP returns content as an array of typed blocks. Join text blocks; fall back to
// a compact JSON dump for non-text blocks so nothing is silently dropped.
function flattenContent(content) {
if (!Array.isArray(content))
return typeof content === "string" ? content : JSON.stringify(content);
const parts = [];
for (const block of content) {
if (block && typeof block === "object" && block.type === "text") {
parts.push(String(block.text ?? ""));
}
else {
parts.push(JSON.stringify(block));
}
}
return parts.join("\n");
}
+43
View File
@@ -0,0 +1,43 @@
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
export const CONFIG_PATH = join(homedir(), ".neuron", "connectors.json");
const SANDBOX = join(homedir(), "neuron-connectd-sandbox");
// Phase-1 default: one zero-auth filesystem server scoped to the sandbox dir.
// Once ~/.neuron/connectors.json exists, it wins.
function defaultConfig() {
return {
servers: {
filesystem: {
transport: "stdio",
enabled: true,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", SANDBOX],
},
},
};
}
export function loadConfig() {
try {
const raw = readFileSync(CONFIG_PATH, "utf8");
const parsed = JSON.parse(raw);
if (!parsed.servers || typeof parsed.servers !== "object") {
return defaultConfig();
}
return parsed;
}
catch {
// No config file yet — run the Phase-1 default so the bridge is useful out of the box.
return defaultConfig();
}
}
// Atomic write of connectors.json (temp + rename), 0600. The bridge owns all writes so the
// soul stays simple and El never has to manipulate JSON. UI edits flow: UI → soul route → bridge.
export function saveConfig(config) {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
const tmp = `${CONFIG_PATH}.tmp`;
writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
renameSync(tmp, CONFIG_PATH);
}
export const PORT = Number(process.env.NEURON_CONNECTD_PORT ?? 7771);
export const HOST = "127.0.0.1"; // loopback only — never bind 0.0.0.0
+20
View File
@@ -0,0 +1,20 @@
import { loadConfig } from "./config.js";
import { ConnectorBridge } from "./bridge.js";
import { startServer } from "./server.js";
// neuron-connectd — the MCP-connector Accessor.
//
// Isolates the MCP wire protocol behind a flat loopback HTTP contract the El soul
// consumes via curl/exec_capture. The soul stays El-simple; this process absorbs
// stdio framing, (Phase 3) SSE + OAuth, and per-server lifecycle.
async function main() {
const config = loadConfig();
const bridge = new ConnectorBridge();
await bridge.start(config);
const total = bridge.allTools().length;
console.error(`[connectd] ready — ${total} tool(s) across ${bridge.serverStatuses().length} server(s)`);
startServer(bridge);
}
main().catch((err) => {
console.error("[connectd] fatal:", err);
process.exit(1);
});
+48
View File
@@ -0,0 +1,48 @@
import { execFile } from "node:child_process";
// macOS Keychain store for connector secrets. Mirrors the pattern Backend.kt
// uses for the soul's provider key (service "ai.neuron.soul"); connectors live
// under their own service so they never collide with the LLM key.
//
// Account convention:
// <serverId> → static bearer token (auth: "token")
// <serverId>:oauth → JSON-encoded OAuthTokens blob (auth: "oauth")
// <serverId>:verifier → transient PKCE code_verifier during a flow
// <serverId>:client → JSON OAuthClientInformation from dynamic registration
const SERVICE = "ai.neuron.connect";
function run(args) {
return new Promise((resolve) => {
execFile("/usr/bin/security", args, (err, stdout) => {
resolve({ code: err ? (err.code ?? 1) : 0, stdout: stdout ?? "" });
});
});
}
export async function kcGet(account) {
const { code, stdout } = await run(["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
if (code !== 0)
return undefined;
const v = stdout.replace(/\n$/, "");
return v.length ? v : undefined;
}
export async function kcSet(account, secret) {
// -U updates in place if the item already exists.
const { code } = await run(["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret]);
return code === 0;
}
export async function kcDelete(account) {
await run(["delete-generic-password", "-s", SERVICE, "-a", account]);
}
// JSON helpers for the OAuth blobs.
export async function kcGetJson(account) {
const raw = await kcGet(account);
if (!raw)
return undefined;
try {
return JSON.parse(raw);
}
catch {
return undefined;
}
}
export async function kcSetJson(account, value) {
return kcSet(account, JSON.stringify(value));
}
+78
View File
@@ -0,0 +1,78 @@
import { kcGet, kcSet, kcDelete, kcGetJson, kcSetJson } from "./keychain.js";
import { HOST, PORT } from "./config.js";
// When the SDK needs the user to authorize, it calls redirectToAuthorization()
// with the provider URL. A headless bridge can't open a browser itself, so it
// stashes the URL here keyed by serverId; POST /mcp/oauth/start returns it for
// the UI to open. Cleared once consumed.
const pendingAuthUrls = new Map();
export function takePendingAuthUrl(serverId) {
const u = pendingAuthUrls.get(serverId);
pendingAuthUrls.delete(serverId);
return u;
}
// OAuthClientProvider whose entire persistent state lives in the macOS Keychain
// (service ai.neuron.connect). One instance per serverId. Survives bridge
// restarts: tokens, the dynamic-registration client info, and the in-flight PKCE
// verifier are all keyed under <serverId>:*.
export class KeychainOAuthProvider {
serverId;
scope;
constructor(serverId, scope) {
this.serverId = serverId;
this.scope = scope;
}
get redirectUrl() {
return `http://${HOST}:${PORT}/mcp/oauth/callback?id=${encodeURIComponent(this.serverId)}`;
}
get clientMetadata() {
return {
client_name: `Neuron (${this.serverId})`,
redirect_uris: [this.redirectUrl],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none", // public client + PKCE
...(this.scope ? { scope: this.scope } : {}),
};
}
// Carry the serverId through the round-trip so the callback knows which
// connector to finish, even across a bridge restart.
state() {
return this.serverId;
}
async clientInformation() {
return kcGetJson(`${this.serverId}:client`);
}
async saveClientInformation(info) {
await kcSetJson(`${this.serverId}:client`, info);
}
async tokens() {
return kcGetJson(`${this.serverId}:oauth`);
}
async saveTokens(tokens) {
await kcSetJson(`${this.serverId}:oauth`, tokens);
}
async redirectToAuthorization(url) {
pendingAuthUrls.set(this.serverId, url.toString());
}
async saveCodeVerifier(verifier) {
await kcSet(`${this.serverId}:verifier`, verifier);
}
async codeVerifier() {
const v = await kcGet(`${this.serverId}:verifier`);
if (!v)
throw new Error(`no PKCE verifier stored for ${this.serverId}`);
return v;
}
async invalidateCredentials(scope) {
if (scope === "all" || scope === "tokens")
await kcDelete(`${this.serverId}:oauth`);
if (scope === "all" || scope === "client")
await kcDelete(`${this.serverId}:client`);
if (scope === "all" || scope === "verifier")
await kcDelete(`${this.serverId}:verifier`);
}
// True once we hold an access token (used for status reporting).
async hasTokens() {
return (await this.tokens()) !== undefined;
}
}
+159
View File
@@ -0,0 +1,159 @@
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 ? "&#10003;" : "&#10007;"}</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}`);
});
}
+1712
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "neuron-connectd",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Neuron MCP-connector bridge (Accessor). Isolates the MCP wire protocol (stdio framing, SSE, OAuth, server lifecycle) behind a flat loopback HTTP contract the El soul consumes.",
"bin": {
"neuron-connectd": "./dist/index.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"@types/node": "^24.0.0"
}
}
+356
View File
@@ -0,0 +1,356 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
import { createHash } from "node:crypto";
import type { ConnectorsConfig, ServerConfig } from "./config.js";
import { saveConfig } from "./config.js";
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
import { kcGet, kcSet, kcDelete } from "./keychain.js";
// Namespacing: every external tool is exposed to the soul as
// mcp__<serverId>__<toolName>
// so the soul can route a call back to the owning server by prefix alone.
const PREFIX = "mcp__";
const SEP = "__";
export function namespacedName(serverId: string, tool: string): string {
return `${PREFIX}${serverId}${SEP}${tool}`;
}
// Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }.
// Tool names may themselves contain "__", so we only split on the FIRST two seps.
export function parseName(full: string): { serverId: string; tool: string } | null {
if (!full.startsWith(PREFIX)) return null;
const rest = full.slice(PREFIX.length);
const i = rest.indexOf(SEP);
if (i < 0) return null;
return { serverId: rest.slice(0, i), tool: rest.slice(i + SEP.length) };
}
// Shape the soul consumes: Anthropic tool format (snake_case input_schema).
export interface ExposedTool {
name: string;
description: string;
input_schema: unknown;
}
type ServerStatus = "connected" | "needs_auth" | "error" | "disabled";
interface ServerEntry {
id: string;
cfg: ServerConfig;
client?: Client;
// Retained for OAuth: finishAuth() must run on the SAME transport instance
// that began the flow.
pendingTransport?: StreamableHTTPClientTransport;
pendingProvider?: KeychainOAuthProvider;
status: ServerStatus;
tools: ExposedTool[];
error?: string;
// Phase 5 tool-poisoning guard: true when this server's tool schemas changed since the
// last connect. Stays set until the user re-enables / acknowledges (clears on toggle).
schemaChanged?: boolean;
}
export class ConnectorBridge {
private servers = new Map<string, ServerEntry>();
// The live config the bridge owns and persists. UI edits mutate this and call saveConfig.
private config: ConnectorsConfig = { servers: {} };
async start(config: ConnectorsConfig): Promise<void> {
this.config = config;
const ids = Object.keys(config.servers);
await Promise.all(ids.map((id) => this.connectServer(id, config.servers[id])));
}
// ── Config CRUD (Phase 4 UI backs onto these via the soul's /api/connectors routes) ──
// Add or replace a server, persist, and connect it. Returns its fresh status.
async addServer(id: string, cfg: ServerConfig): Promise<{ ok: boolean; error?: string }> {
if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) return { ok: false, error: "invalid server id" };
this.config.servers[id] = cfg;
saveConfig(this.config);
await this.reconnectServer(id);
const s = this.servers.get(id);
return s && s.status === "error" ? { ok: false, error: s.error } : { ok: true };
}
async setEnabled(id: string, enabled: boolean): Promise<{ ok: boolean; error?: string }> {
const cfg = this.config.servers[id];
if (!cfg) return { ok: false, error: `unknown server: ${id}` };
cfg.enabled = enabled;
// Re-enabling acknowledges any flagged schema change (user is opting back in).
if (enabled) delete cfg.schemaHash;
saveConfig(this.config);
await this.reconnectServer(id);
return { ok: true };
}
async setAutoApprove(id: string, autoApprove: boolean): Promise<{ ok: boolean; error?: string }> {
const cfg = this.config.servers[id];
if (!cfg) return { ok: false, error: `unknown server: ${id}` };
cfg.autoApprove = autoApprove;
saveConfig(this.config);
const entry = this.servers.get(id);
if (entry) entry.cfg = cfg;
return { ok: true };
}
// Store a connector's API token in the Keychain (account = serverId), then reconnect.
// Used for http auth:"token" servers; the secret never lands in connectors.json.
async setSecret(id: string, secret: string): Promise<{ ok: boolean; error?: string }> {
if (!secret) return { ok: false, error: "empty secret" };
const stored = await kcSet(id, secret);
if (!stored) return { ok: false, error: "keychain write failed" };
if (this.config.servers[id]) await this.reconnectServer(id);
return { ok: true };
}
async removeServer(id: string): Promise<{ ok: boolean; error?: string }> {
if (!this.config.servers[id]) return { ok: false, error: `unknown server: ${id}` };
await this.closeServer(id);
delete this.config.servers[id];
this.servers.delete(id);
saveConfig(this.config);
await kcDelete(id).catch(() => {}); // drop any stored secret for this connector
return { ok: true };
}
// Tear down an existing client (if any) and connect fresh from current config.
private async reconnectServer(id: string): Promise<void> {
await this.closeServer(id);
const cfg = this.config.servers[id];
if (cfg) await this.connectServer(id, cfg);
}
private async closeServer(id: string): Promise<void> {
const entry = this.servers.get(id);
if (entry?.client) {
try { await entry.client.close(); } catch { /* best effort */ }
}
}
private async connectServer(id: string, cfg: ServerConfig): Promise<void> {
if (!cfg.enabled) {
this.servers.set(id, { id, cfg, status: "disabled", tools: [] });
return;
}
if (cfg.transport === "stdio") return this.connectStdio(id, cfg);
if (cfg.transport === "http") return this.connectHttp(id, cfg);
this.servers.set(id, { id, cfg, status: "error", tools: [], error: `unknown transport "${cfg.transport}"` });
}
private async connectStdio(id: string, cfg: ServerConfig): Promise<void> {
try {
const transport = new StdioClientTransport({
command: cfg.command!,
args: cfg.args ?? [],
env: { ...process.env, ...(cfg.env ?? {}) } as Record<string, string>,
});
await this.finishConnect(id, cfg, transport);
} catch (err) {
this.setError(id, cfg, err);
}
}
// Remote MCP over Streamable HTTP. Two auth modes:
// auth: "token" → static bearer from Keychain account <serverId>
// auth: "oauth" → SDK-driven PKCE; needs_auth until the user signs in
private async connectHttp(id: string, cfg: ServerConfig): Promise<void> {
if (!cfg.url) {
this.servers.set(id, { id, cfg, status: "error", tools: [], error: "http transport requires a url" });
return;
}
const url = new URL(cfg.url);
if (cfg.auth === "oauth") {
const provider = new KeychainOAuthProvider(id, cfg.scope);
const transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
try {
await this.finishConnect(id, cfg, transport);
} catch (err) {
if (err instanceof UnauthorizedError) {
// Provider stashed the auth URL; keep the transport so /oauth/callback
// can finishAuth on it. Surface needs_auth to the UI.
this.servers.set(id, {
id, cfg, status: "needs_auth", tools: [],
pendingTransport: transport, pendingProvider: provider,
error: "sign-in required",
});
console.error(`[connectd] ${id}: needs OAuth sign-in`);
} else {
this.setError(id, cfg, err);
}
}
return;
}
// token (or none)
let requestInit: RequestInit | undefined;
if (cfg.auth === "token") {
const token = await kcGet(id);
if (!token) {
this.servers.set(id, { id, cfg, status: "needs_auth", tools: [], error: "no token in Keychain" });
return;
}
requestInit = { headers: { Authorization: `Bearer ${token}` } };
}
try {
const transport = new StreamableHTTPClientTransport(url, { requestInit });
await this.finishConnect(id, cfg, transport);
} catch (err) {
this.setError(id, cfg, err);
}
}
// Connect a client over the given transport, list tools, record as connected.
private async finishConnect(
id: string,
cfg: ServerConfig,
transport: StdioClientTransport | StreamableHTTPClientTransport
): Promise<void> {
const client = new Client({ name: "neuron-connectd", version: "0.1.0" }, { capabilities: {} });
await client.connect(transport);
const listed = await client.listTools();
const tools: ExposedTool[] = listed.tools.map((t) => ({
name: namespacedName(id, t.name),
description: t.description ?? "",
input_schema: t.inputSchema ?? { type: "object", properties: {} },
}));
// Tool-poisoning guard: hash name+description+schema of every tool. First connect pins the
// hash; a later mismatch flags the server in the UI (a silently-changed tool description is a
// prompt-injection vector) until the user re-enables it.
const hash = schemaHashOf(tools);
let schemaChanged = false;
if (cfg.schemaHash && cfg.schemaHash !== hash) {
schemaChanged = true;
} else if (!cfg.schemaHash) {
cfg.schemaHash = hash;
this.config.servers[id] = cfg;
saveConfig(this.config);
}
this.servers.set(id, { id, cfg, client, status: "connected", tools, schemaChanged });
console.error(`[connectd] ${id}: connected (${cfg.transport}), ${tools.length} tools${schemaChanged ? " [SCHEMA CHANGED]" : ""}`);
}
private setError(id: string, cfg: ServerConfig, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
this.servers.set(id, { id, cfg, status: "error", tools: [], error: msg });
console.error(`[connectd] ${id}: connect failed — ${msg}`);
}
// Begin an OAuth sign-in: returns the authorization URL for the UI to open.
async startOAuth(id: string): Promise<{ ok: boolean; authUrl?: string; error?: string }> {
const entry = this.servers.get(id);
if (!entry) return { ok: false, error: `unknown server: ${id}` };
if (entry.cfg.transport !== "http" || entry.cfg.auth !== "oauth") {
return { ok: false, error: `server ${id} is not an OAuth connector` };
}
// Re-drive the connect to (re)generate the auth URL if not already pending.
if (entry.status !== "needs_auth" || !entry.pendingTransport) {
await this.connectHttp(id, entry.cfg);
}
const url = takePendingAuthUrl(id);
if (!url) return { ok: false, error: "no authorization URL produced (server may not require OAuth)" };
return { ok: true, authUrl: url };
}
// Complete an OAuth sign-in with the authorization code from the callback.
async finishOAuth(id: string, code: string): Promise<{ ok: boolean; error?: string }> {
const entry = this.servers.get(id);
if (!entry?.pendingTransport) return { ok: false, error: `no pending OAuth flow for ${id}` };
try {
await entry.pendingTransport.finishAuth(code);
// Tokens are now in Keychain; reconnect cleanly to list tools.
await this.connectHttp(id, entry.cfg);
const now = this.servers.get(id);
return now?.status === "connected"
? { ok: true }
: { ok: false, error: `post-auth status: ${now?.status} (${now?.error ?? ""})` };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// Merged, namespaced tool list across all connected servers.
allTools(): ExposedTool[] {
const out: ExposedTool[] = [];
for (const s of this.servers.values()) out.push(...s.tools);
return out;
}
// Per-server status for the UI / health endpoint.
serverStatuses(): Array<{
id: string; status: ServerStatus; transport: string; tools: number;
enabled: boolean; auth?: string; autoApprove: boolean; schemaChanged: boolean; error?: string;
}> {
return [...this.servers.values()].map((s) => ({
id: s.id,
status: s.status,
transport: s.cfg.transport,
tools: s.tools.length,
enabled: s.cfg.enabled,
auth: s.cfg.auth,
autoApprove: s.cfg.autoApprove === true,
schemaChanged: s.schemaChanged === true,
error: s.error,
}));
}
// The set of connector tool names that are auto-approved (the soul reads this to decide
// whether an mcp__* call skips the approval card). Only connected, enabled, opted-in servers.
autoApprovedTools(): string[] {
const out: string[] = [];
for (const s of this.servers.values()) {
if (s.cfg.autoApprove && s.status === "connected") out.push(...s.tools.map((t) => t.name));
}
return out;
}
// Route a namespaced call to the owning server and flatten the result to a string
// (the soul does json_get(out,"content") then truncates at 6000 chars).
async call(full: string, input: unknown): Promise<{ ok: boolean; content?: string; error?: string }> {
const parsed = parseName(full);
if (!parsed) return { ok: false, error: `not an mcp tool name: ${full}` };
const entry = this.servers.get(parsed.serverId);
if (!entry || !entry.client) {
const why = entry?.status === "needs_auth" ? "needs_auth" : `server not connected: ${parsed.serverId}`;
return { ok: false, error: why };
}
try {
const res = await entry.client.callTool({
name: parsed.tool,
arguments: (input ?? {}) as Record<string, unknown>,
});
return { ok: true, content: flattenContent(res.content) };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
}
// Stable SHA-256 over each tool's name + description + input schema. Sorted by name so tool
// ordering doesn't churn the hash; only real content changes do.
function schemaHashOf(tools: ExposedTool[]): string {
const canon = [...tools]
.sort((a, b) => a.name.localeCompare(b.name))
.map((t) => `${t.name}${t.description}${JSON.stringify(t.input_schema)}`)
.join("");
return createHash("sha256").update(canon).digest("hex");
}
// MCP returns content as an array of typed blocks. Join text blocks; fall back to
// a compact JSON dump for non-text blocks so nothing is silently dropped.
function flattenContent(content: unknown): string {
if (!Array.isArray(content)) return typeof content === "string" ? content : JSON.stringify(content);
const parts: string[] = [];
for (const block of content) {
if (block && typeof block === "object" && (block as any).type === "text") {
parts.push(String((block as any).text ?? ""));
} else {
parts.push(JSON.stringify(block));
}
}
return parts.join("\n");
}
+72
View File
@@ -0,0 +1,72 @@
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import { homedir } from "node:os";
import { join, dirname } from "node:path";
// A single configured MCP server. Phase 1 supports stdio only; `http` transport
// (remote MCP + OAuth) lands in Phase 3.
export interface ServerConfig {
transport: "stdio" | "http";
enabled: boolean;
// stdio
command?: string;
args?: string[];
env?: Record<string, string>;
// http (Phase 3)
url?: string;
auth?: "none" | "oauth" | "token";
scope?: string; // OAuth scope string, when auth === "oauth"
// Phase 5: per-connector opt-in to skip the soul's approval card (read-only-leaning,
// off by default). The soul reads this from connectors.json at approval time.
autoApprove?: boolean;
// Phase 5: tool-poisoning guard. Last-known hash of this server's tool schemas; if the
// server silently changes a tool description on reconnect, the bridge flags it in the UI.
schemaHash?: string;
}
export interface ConnectorsConfig {
servers: Record<string, ServerConfig>;
}
export const CONFIG_PATH = join(homedir(), ".neuron", "connectors.json");
const SANDBOX = join(homedir(), "neuron-connectd-sandbox");
// Phase-1 default: one zero-auth filesystem server scoped to the sandbox dir.
// Once ~/.neuron/connectors.json exists, it wins.
function defaultConfig(): ConnectorsConfig {
return {
servers: {
filesystem: {
transport: "stdio",
enabled: true,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", SANDBOX],
},
},
};
}
export function loadConfig(): ConnectorsConfig {
try {
const raw = readFileSync(CONFIG_PATH, "utf8");
const parsed = JSON.parse(raw) as ConnectorsConfig;
if (!parsed.servers || typeof parsed.servers !== "object") {
return defaultConfig();
}
return parsed;
} catch {
// No config file yet — run the Phase-1 default so the bridge is useful out of the box.
return defaultConfig();
}
}
// Atomic write of connectors.json (temp + rename), 0600. The bridge owns all writes so the
// soul stays simple and El never has to manipulate JSON. UI edits flow: UI → soul route → bridge.
export function saveConfig(config: ConnectorsConfig): void {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
const tmp = `${CONFIG_PATH}.tmp`;
writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
renameSync(tmp, CONFIG_PATH);
}
export const PORT = Number(process.env.NEURON_CONNECTD_PORT ?? 7771);
export const HOST = "127.0.0.1"; // loopback only — never bind 0.0.0.0
+24
View File
@@ -0,0 +1,24 @@
import { loadConfig } from "./config.js";
import { ConnectorBridge } from "./bridge.js";
import { startServer } from "./server.js";
// neuron-connectd — the MCP-connector Accessor.
//
// Isolates the MCP wire protocol behind a flat loopback HTTP contract the El soul
// consumes via curl/exec_capture. The soul stays El-simple; this process absorbs
// stdio framing, (Phase 3) SSE + OAuth, and per-server lifecycle.
async function main(): Promise<void> {
const config = loadConfig();
const bridge = new ConnectorBridge();
await bridge.start(config);
const total = bridge.allTools().length;
console.error(`[connectd] ready — ${total} tool(s) across ${bridge.serverStatuses().length} server(s)`);
startServer(bridge);
}
main().catch((err) => {
console.error("[connectd] fatal:", err);
process.exit(1);
});
+52
View File
@@ -0,0 +1,52 @@
import { execFile } from "node:child_process";
// macOS Keychain store for connector secrets. Mirrors the pattern Backend.kt
// uses for the soul's provider key (service "ai.neuron.soul"); connectors live
// under their own service so they never collide with the LLM key.
//
// Account convention:
// <serverId> → static bearer token (auth: "token")
// <serverId>:oauth → JSON-encoded OAuthTokens blob (auth: "oauth")
// <serverId>:verifier → transient PKCE code_verifier during a flow
// <serverId>:client → JSON OAuthClientInformation from dynamic registration
const SERVICE = "ai.neuron.connect";
function run(args: string[]): Promise<{ code: number; stdout: string }> {
return new Promise((resolve) => {
execFile("/usr/bin/security", args, (err, stdout) => {
resolve({ code: err ? ((err as any).code ?? 1) : 0, stdout: stdout ?? "" });
});
});
}
export async function kcGet(account: string): Promise<string | undefined> {
const { code, stdout } = await run(["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
if (code !== 0) return undefined;
const v = stdout.replace(/\n$/, "");
return v.length ? v : undefined;
}
export async function kcSet(account: string, secret: string): Promise<boolean> {
// -U updates in place if the item already exists.
const { code } = await run(["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret]);
return code === 0;
}
export async function kcDelete(account: string): Promise<void> {
await run(["delete-generic-password", "-s", SERVICE, "-a", account]);
}
// JSON helpers for the OAuth blobs.
export async function kcGetJson<T>(account: string): Promise<T | undefined> {
const raw = await kcGet(account);
if (!raw) return undefined;
try {
return JSON.parse(raw) as T;
} catch {
return undefined;
}
}
export async function kcSetJson(account: string, value: unknown): Promise<boolean> {
return kcSet(account, JSON.stringify(value));
}
+96
View File
@@ -0,0 +1,96 @@
import type {
OAuthClientProvider,
} from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthTokens,
OAuthClientMetadata,
OAuthClientInformation,
OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { kcGet, kcSet, kcDelete, kcGetJson, kcSetJson } from "./keychain.js";
import { HOST, PORT } from "./config.js";
// When the SDK needs the user to authorize, it calls redirectToAuthorization()
// with the provider URL. A headless bridge can't open a browser itself, so it
// stashes the URL here keyed by serverId; POST /mcp/oauth/start returns it for
// the UI to open. Cleared once consumed.
const pendingAuthUrls = new Map<string, string>();
export function takePendingAuthUrl(serverId: string): string | undefined {
const u = pendingAuthUrls.get(serverId);
pendingAuthUrls.delete(serverId);
return u;
}
// OAuthClientProvider whose entire persistent state lives in the macOS Keychain
// (service ai.neuron.connect). One instance per serverId. Survives bridge
// restarts: tokens, the dynamic-registration client info, and the in-flight PKCE
// verifier are all keyed under <serverId>:*.
export class KeychainOAuthProvider implements OAuthClientProvider {
constructor(
private readonly serverId: string,
private readonly scope?: string
) {}
get redirectUrl(): string {
return `http://${HOST}:${PORT}/mcp/oauth/callback?id=${encodeURIComponent(this.serverId)}`;
}
get clientMetadata(): OAuthClientMetadata {
return {
client_name: `Neuron (${this.serverId})`,
redirect_uris: [this.redirectUrl],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none", // public client + PKCE
...(this.scope ? { scope: this.scope } : {}),
};
}
// Carry the serverId through the round-trip so the callback knows which
// connector to finish, even across a bridge restart.
state(): string {
return this.serverId;
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
return kcGetJson<OAuthClientInformation>(`${this.serverId}:client`);
}
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
await kcSetJson(`${this.serverId}:client`, info);
}
async tokens(): Promise<OAuthTokens | undefined> {
return kcGetJson<OAuthTokens>(`${this.serverId}:oauth`);
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await kcSetJson(`${this.serverId}:oauth`, tokens);
}
async redirectToAuthorization(url: URL): Promise<void> {
pendingAuthUrls.set(this.serverId, url.toString());
}
async saveCodeVerifier(verifier: string): Promise<void> {
await kcSet(`${this.serverId}:verifier`, verifier);
}
async codeVerifier(): Promise<string> {
const v = await kcGet(`${this.serverId}:verifier`);
if (!v) throw new Error(`no PKCE verifier stored for ${this.serverId}`);
return v;
}
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
if (scope === "all" || scope === "tokens") await kcDelete(`${this.serverId}:oauth`);
if (scope === "all" || scope === "client") await kcDelete(`${this.serverId}:client`);
if (scope === "all" || scope === "verifier") await kcDelete(`${this.serverId}:verifier`);
}
// True once we hold an access token (used for status reporting).
async hasTokens(): Promise<boolean> {
return (await this.tokens()) !== undefined;
}
}
+167
View File
@@ -0,0 +1,167 @@
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { HOST, PORT } from "./config.js";
import type { ConnectorBridge } from "./bridge.js";
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body);
res.writeHead(status, {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
});
res.end(payload);
}
function sendHtml(res: ServerResponse, status: number, html: string): void {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
}
function authResultPage(ok: boolean, detail: string): string {
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 ? "&#10003;" : "&#10007;"}</div><p>${msg}</p></body>`;
}
function readBody(req: IncomingMessage): Promise<string> {
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<T>(req: IncomingMessage): Promise<T | null> {
const raw = await readBody(req);
try {
return JSON.parse(raw || "{}") as T;
} catch {
return null;
}
}
export function startServer(bridge: ConnectorBridge): void {
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: { id?: string };
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<{ id?: string; config?: any }>(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<{ id?: string; enabled?: boolean }>(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<{ id?: string; autoApprove?: boolean }>(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<{ id?: string; secret?: string }>(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<{ id?: string }>(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: { name?: string; input?: unknown };
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}`);
});
}
+47
View File
@@ -0,0 +1,47 @@
// A minimal remote MCP server over Streamable HTTP that REQUIRES a bearer token.
// Used to verify neuron-connectd's http transport + Keychain-backed auth end to
// end, with no external dependency. Wrong/missing token → 401.
//
// TEST_MCP_TOKEN=secret-123 TEST_MCP_PORT=7795 node test/auth-http-mcp-server.mjs
import http from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const TOKEN = process.env.TEST_MCP_TOKEN || "secret-123";
const PORT = Number(process.env.TEST_MCP_PORT || 7795);
function buildServer() {
const server = new McpServer({ name: "auth-test", version: "0.1.0" });
server.registerTool(
"whoami",
{ description: "Return the authenticated identity.", inputSchema: {} },
async () => ({ content: [{ type: "text", text: "authenticated-ok: bearer token accepted" }] })
);
return server;
}
http
.createServer(async (req, res) => {
// Bearer auth gate — the whole point of the test.
const auth = req.headers["authorization"] || "";
if (auth !== `Bearer ${TOKEN}`) {
res.writeHead(401, { "content-type": "application/json", "www-authenticate": "Bearer" });
res.end(JSON.stringify({ error: "unauthorized" }));
return;
}
let body = "";
for await (const chunk of req) body += chunk;
const parsed = body ? JSON.parse(body) : undefined;
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, parsed);
})
.listen(PORT, "127.0.0.1", () => {
console.error(`[auth-test-mcp] listening on http://127.0.0.1:${PORT} (token gated)`);
});
+49
View File
@@ -0,0 +1,49 @@
// Local verification of the OAuth provider's durable mechanics (no external IdP):
// the KeychainOAuthProvider must round-trip tokens, client info, and the PKCE
// verifier through the macOS Keychain so a sign-in survives bridge restarts.
import { KeychainOAuthProvider } from "../src/oauth.js";
import { kcDelete } from "../src/keychain.js";
const ID = "oauth-selftest";
const p = new KeychainOAuthProvider(ID, "drive.readonly");
let failures = 0;
const check = (label: string, cond: boolean) => {
console.log(`${cond ? "ok " : "FAIL"} ${label}`);
if (!cond) failures++;
};
// metadata + redirect shape
check("redirectUrl points at loopback callback with id",
p.redirectUrl.includes("/mcp/oauth/callback?id=oauth-selftest"));
check("clientMetadata is PKCE public client",
p.clientMetadata.token_endpoint_auth_method === "none" &&
p.clientMetadata.response_types.includes("code"));
check("state carries serverId", p.state() === ID);
// tokens round-trip through Keychain
await p.saveTokens({ access_token: "tok-abc", token_type: "bearer", expires_in: 3600 });
const t = await p.tokens();
check("tokens persisted + read back", t?.access_token === "tok-abc");
check("hasTokens true after save", await p.hasTokens());
// client info (dynamic registration) round-trip
await p.saveClientInformation({ client_id: "cid-1", redirect_uris: [p.redirectUrl] } as any);
const ci = await p.clientInformation();
check("client info persisted + read back", (ci as any)?.client_id === "cid-1");
// PKCE verifier round-trip
await p.saveCodeVerifier("verifier-xyz");
check("code verifier persisted + read back", (await p.codeVerifier()) === "verifier-xyz");
// invalidate clears everything
await p.invalidateCredentials("all");
check("invalidate(all) clears tokens", !(await p.hasTokens()));
// cleanup any stragglers
await kcDelete(`${ID}:oauth`);
await kcDelete(`${ID}:client`);
await kcDelete(`${ID}:verifier`);
console.log(failures === 0 ? "\nALL OAUTH MECHANICS OK" : `\n${failures} FAILURE(S)`);
process.exit(failures === 0 ? 0 : 1);
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*.ts"]
}