commit fdf8fb5cda175bee6579fea35507154cfe002d34 Author: Tim Lingo Date: Sat Jun 13 18:45:55 2026 -0500 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..659dc09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.skills-sync* diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0e11d8 --- /dev/null +++ b/README.md @@ -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____", 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____`; `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 ``. 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 (`: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. diff --git a/dist/bridge.js b/dist/bridge.js new file mode 100644 index 0000000..78065c6 --- /dev/null +++ b/dist/bridge.js @@ -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____ +// 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 + // 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"); +} diff --git a/dist/config.js b/dist/config.js new file mode 100644 index 0000000..2206111 --- /dev/null +++ b/dist/config.js @@ -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 diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..dcdc88b --- /dev/null +++ b/dist/index.js @@ -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); +}); diff --git a/dist/keychain.js b/dist/keychain.js new file mode 100644 index 0000000..abf478c --- /dev/null +++ b/dist/keychain.js @@ -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: +// → static bearer token (auth: "token") +// :oauth → JSON-encoded OAuthTokens blob (auth: "oauth") +// :verifier → transient PKCE code_verifier during a flow +// :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)); +} diff --git a/dist/oauth.js b/dist/oauth.js new file mode 100644 index 0000000..4bac1c2 --- /dev/null +++ b/dist/oauth.js @@ -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 :*. +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; + } +} diff --git a/dist/server.js b/dist/server.js new file mode 100644 index 0000000..c566672 --- /dev/null +++ b/dist/server.js @@ -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 `Neuron Connectors` + + `` + + `
${ok ? "✓" : "✗"}

${msg}

`; +} +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}`); + }); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..76a84d7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1712 @@ +{ + "name": "neuron-connectd", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "neuron-connectd", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "bin": { + "neuron-connectd": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a36e7f0 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/bridge.ts b/src/bridge.ts new file mode 100644 index 0000000..b7921c8 --- /dev/null +++ b/src/bridge.ts @@ -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____ +// 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(); + // The live config the bridge owns and persists. UI edits mutate this and call saveConfig. + private config: ConnectorsConfig = { servers: {} }; + + async start(config: ConnectorsConfig): Promise { + 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 { + await this.closeServer(id); + const cfg = this.config.servers[id]; + if (cfg) await this.connectServer(id, cfg); + } + + private async closeServer(id: string): Promise { + 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 { + 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 { + try { + const transport = new StdioClientTransport({ + command: cfg.command!, + args: cfg.args ?? [], + env: { ...process.env, ...(cfg.env ?? {}) } as Record, + }); + 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 + // auth: "oauth" → SDK-driven PKCE; needs_auth until the user signs in + private async connectHttp(id: string, cfg: ServerConfig): Promise { + 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 { + 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, + }); + 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"); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..a16cc51 --- /dev/null +++ b/src/config.ts @@ -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; + // 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; +} + +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 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a8ea66b --- /dev/null +++ b/src/index.ts @@ -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 { + 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); +}); diff --git a/src/keychain.ts b/src/keychain.ts new file mode 100644 index 0000000..9d55d16 --- /dev/null +++ b/src/keychain.ts @@ -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: +// → static bearer token (auth: "token") +// :oauth → JSON-encoded OAuthTokens blob (auth: "oauth") +// :verifier → transient PKCE code_verifier during a flow +// :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 { + 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 { + // -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 { + await run(["delete-generic-password", "-s", SERVICE, "-a", account]); +} + +// JSON helpers for the OAuth blobs. +export async function kcGetJson(account: string): Promise { + 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 { + return kcSet(account, JSON.stringify(value)); +} diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..168d42f --- /dev/null +++ b/src/oauth.ts @@ -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(); + +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 :*. +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 { + return kcGetJson(`${this.serverId}:client`); + } + + async saveClientInformation(info: OAuthClientInformationFull): Promise { + await kcSetJson(`${this.serverId}:client`, info); + } + + async tokens(): Promise { + return kcGetJson(`${this.serverId}:oauth`); + } + + async saveTokens(tokens: OAuthTokens): Promise { + await kcSetJson(`${this.serverId}:oauth`, tokens); + } + + async redirectToAuthorization(url: URL): Promise { + pendingAuthUrls.set(this.serverId, url.toString()); + } + + async saveCodeVerifier(verifier: string): Promise { + await kcSet(`${this.serverId}:verifier`, verifier); + } + + async codeVerifier(): Promise { + 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 { + 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 { + return (await this.tokens()) !== undefined; + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..6e18bd0 --- /dev/null +++ b/src/server.ts @@ -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 `Neuron Connectors` + + `` + + `
${ok ? "✓" : "✗"}

${msg}

`; +} + +function readBody(req: IncomingMessage): Promise { + 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: IncomingMessage): Promise { + 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}`); + }); +} diff --git a/test/auth-http-mcp-server.mjs b/test/auth-http-mcp-server.mjs new file mode 100644 index 0000000..cab9fbe --- /dev/null +++ b/test/auth-http-mcp-server.mjs @@ -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)`); + }); diff --git a/test/verify-oauth.ts b/test/verify-oauth.ts new file mode 100644 index 0000000..4212f55 --- /dev/null +++ b/test/verify-oauth.ts @@ -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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..651c832 --- /dev/null +++ b/tsconfig.json @@ -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"] +}