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; // Companion sidecar: a long-running process this connector's MCP server talks to (e.g. the // WhatsApp bridge on :8080 that holds the paired session). connectd starts it as a CHILD when the // connector connects, waits for healthUrl to respond, then spawns the MCP server; it is killed on // disconnect/shutdown. `command` supports @bin//@node//@bundled resolution like the server itself. companion?: { command: string; args?: string[]; env?: Record; healthUrl?: string; // Working directory for the sidecar (created if missing). The WhatsApp bridge writes its session // store relative to cwd, so this must be writable (e.g. ~/.neuron/whatsapp) — never the read-only // app bundle. "~" / "$HOME" at the start are expanded to the user's home. cwd?: string; }; // http (Phase 3) url?: string; auth?: "none" | "oauth" | "token" | "google"; scope?: string; // OAuth scope string, when auth === "oauth" // Google connectors (Drive/Gmail/Calendar): stdio servers whose Google OAuth the bridge // performs itself (one-click), storing tokens in the Keychain. `scopes` = the Google API // scopes to request. Requires a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID); // until that's configured the connector reports needs_setup instead of failing cryptically. scopes?: string[]; // 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; } 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