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:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.skills-sync*
|
||||
@@ -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.
|
||||
Vendored
+327
@@ -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} | ||||