a01236753a
Make connectors work on a clean Mac with no Node/npm installed, and lay
the foundation for one-click Google sign-in.
Clean-install bundling:
- bundle scripts: `npm run bundle` -> self-contained dist/connectd.cjs (CJS,
no node_modules); `bundle:servers` -> pre-bundled MCP servers (fs-server.mjs
for "Your files", runs without npx); `bundle:all`.
- runtime resolution in the bridge: a connector config can use command "@node"
(the bundled runtime, process.execPath) and arg "@bundled/<server>" (a server
shipped alongside the bridge), resolved at spawn time via
NEURON_CONNECTD_BUNDLE_DIR. So remote connectors + "Your files" work offline,
zero deps.
- graceful ENOENT -> needs_setup ("coming soon") instead of a cryptic spawn
crash for not-yet-bundled npx connectors on a clean machine.
Google OAuth foundation (google-oauth.ts):
- desktop-style authorization-code + PKCE flow, refresh, Keychain storage,
gated on GOOGLE_OAUTH_CLIENT_ID (honest needs_setup until configured).
- new auth mode "google" for stdio connectors + /google/oauth/start and
/google/callback endpoints. NOTE: pending the Neuron Google OAuth client +
Google verification; not certified end-to-end yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
181 lines
9.1 KiB
JavaScript
181 lines
9.1 KiB
JavaScript
import { createServer } from "node:http";
|
|
import { HOST, PORT } from "./config.js";
|
|
function sendJson(res, status, body) {
|
|
const payload = JSON.stringify(body);
|
|
res.writeHead(status, {
|
|
"content-type": "application/json",
|
|
"content-length": Buffer.byteLength(payload),
|
|
});
|
|
res.end(payload);
|
|
}
|
|
function sendHtml(res, status, html) {
|
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
res.end(html);
|
|
}
|
|
function authResultPage(ok, detail) {
|
|
const msg = ok ? "Connected. You can close this tab." : `Sign-in failed: ${detail}`;
|
|
return `<!doctype html><meta charset="utf-8"><title>Neuron Connectors</title>` +
|
|
`<body style="font:15px -apple-system,system-ui;margin:18% auto;max-width:420px;text-align:center;color:#222">` +
|
|
`<div style="font-size:42px">${ok ? "✓" : "✗"}</div><p>${msg}</p></body>`;
|
|
}
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
req.on("data", (c) => {
|
|
data += c;
|
|
if (data.length > 5_000_000)
|
|
reject(new Error("body too large"));
|
|
});
|
|
req.on("end", () => resolve(data));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
// Read + JSON-parse a request body; returns null on empty/invalid JSON.
|
|
async function parseJsonBody(req) {
|
|
const raw = await readBody(req);
|
|
try {
|
|
return JSON.parse(raw || "{}");
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
export function startServer(bridge) {
|
|
const server = createServer(async (req, res) => {
|
|
const url = req.url ?? "/";
|
|
const method = req.method ?? "GET";
|
|
try {
|
|
// GET /mcp/tools — merged, namespaced tool schemas for the soul.
|
|
if (method === "GET" && url === "/mcp/tools") {
|
|
return sendJson(res, 200, { tools: bridge.allTools() });
|
|
}
|
|
// GET /mcp/servers — per-server health for the UI.
|
|
if (method === "GET" && url === "/mcp/servers") {
|
|
return sendJson(res, 200, { servers: bridge.serverStatuses() });
|
|
}
|
|
// GET /healthz — liveness.
|
|
if (method === "GET" && url === "/healthz") {
|
|
return sendJson(res, 200, { ok: true });
|
|
}
|
|
// POST /mcp/oauth/start { id } — begin OAuth sign-in, return the auth URL.
|
|
if (method === "POST" && url === "/mcp/oauth/start") {
|
|
const raw = await readBody(req);
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw || "{}");
|
|
}
|
|
catch {
|
|
return sendJson(res, 400, { ok: false, error: "invalid JSON body" });
|
|
}
|
|
if (!parsed.id)
|
|
return sendJson(res, 400, { ok: false, error: "missing id" });
|
|
const result = await bridge.startOAuth(parsed.id);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// GET /mcp/oauth/callback?id=..&code=.. — provider redirects here after consent.
|
|
if (method === "GET" && url.startsWith("/mcp/oauth/callback")) {
|
|
const q = new URL(url, `http://${HOST}:${PORT}`).searchParams;
|
|
const id = q.get("id");
|
|
const code = q.get("code");
|
|
const err = q.get("error");
|
|
if (err)
|
|
return sendHtml(res, 400, authResultPage(false, err));
|
|
if (!id || !code)
|
|
return sendHtml(res, 400, authResultPage(false, "missing id or code"));
|
|
const result = await bridge.finishOAuth(id, code);
|
|
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
|
}
|
|
// POST /google/oauth/start { id } — begin a Google one-click sign-in, return the consent URL.
|
|
if (method === "POST" && url === "/google/oauth/start") {
|
|
const parsed = await parseJsonBody(req);
|
|
if (!parsed || !parsed.id)
|
|
return sendJson(res, 400, { ok: false, error: "missing id" });
|
|
const result = await bridge.startGoogleOAuth(parsed.id);
|
|
return sendJson(res, result.ok ? 200 : 400, result);
|
|
}
|
|
// GET /google/callback?code=..&state=.. — Google redirects here after consent.
|
|
if (method === "GET" && url.startsWith("/google/callback")) {
|
|
const q = new URL(url, `http://${HOST}:${PORT}`).searchParams;
|
|
const id = q.get("state"); // we pass the connector id as `state`
|
|
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.finishGoogleOAuth(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}`);
|
|
});
|
|
}
|