feat(connectors): clean-install bundling + Google OAuth foundation
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>
This commit is contained in:
Vendored
+88
-3
@@ -3,8 +3,10 @@ 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 { join, dirname } from "node:path";
|
||||
import { saveConfig } from "./config.js";
|
||||
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
|
||||
import { googleClientConfigured, buildGoogleAuthUrl, exchangeGoogleCode, getValidGoogleAccessToken, clearGoogleTokens, } from "./google-oauth.js";
|
||||
import { kcGet, kcSet, kcDelete } from "./keychain.js";
|
||||
// Namespacing: every external tool is exposed to the soul as
|
||||
// mcp__<serverId>__<toolName>
|
||||
@@ -14,6 +16,23 @@ const SEP = "__";
|
||||
export function namespacedName(serverId, tool) {
|
||||
return `${PREFIX}${serverId}${SEP}${tool}`;
|
||||
}
|
||||
// ── Self-contained runtime resolution ────────────────────────────────────────────────────────
|
||||
// So connectors work on a clean Mac with no Node/npm installed, a connector config can reference:
|
||||
// command "@node" → the JS runtime running this bridge (process.execPath = the bundled
|
||||
// Node we ship in the .app), used to spawn pre-bundled MCP servers.
|
||||
// arg "@bundled/<file>" → a pre-bundled MCP server shipped alongside the bridge (e.g. the
|
||||
// filesystem server for "Your files"), so it runs offline without npx.
|
||||
// The bundled-servers dir is the app's resources/.../neuron-connectd/servers (set by the daemons
|
||||
// launcher via NEURON_CONNECTD_BUNDLE_DIR; falls back to a path next to the running bundle).
|
||||
function bundledServersDir() {
|
||||
return process.env.NEURON_CONNECTD_BUNDLE_DIR ?? join(dirname(process.argv[1] ?? process.cwd()), "servers");
|
||||
}
|
||||
function resolveRuntimeCommand(command) {
|
||||
return command === "@node" ? process.execPath : command;
|
||||
}
|
||||
function resolveBundledArg(arg) {
|
||||
return arg.startsWith("@bundled/") ? join(bundledServersDir(), arg.slice("@bundled/".length)) : arg;
|
||||
}
|
||||
// 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) {
|
||||
@@ -88,6 +107,7 @@ export class ConnectorBridge {
|
||||
this.servers.delete(id);
|
||||
saveConfig(this.config);
|
||||
await kcDelete(id).catch(() => { }); // drop any stored secret for this connector
|
||||
await clearGoogleTokens(id).catch(() => { }); // and any Google tokens
|
||||
return { ok: true };
|
||||
}
|
||||
// Tear down an existing client (if any) and connect fresh from current config.
|
||||
@@ -118,15 +138,52 @@ export class ConnectorBridge {
|
||||
this.servers.set(id, { id, cfg, status: "error", tools: [], error: `unknown transport "${cfg.transport}"` });
|
||||
}
|
||||
async connectStdio(id, cfg) {
|
||||
// Google connectors (auth: "google") resolve a fresh access token first. Two honest pre-connect
|
||||
// states replace the old "launch npx with no creds and crash" failure:
|
||||
// needs_setup — the Neuron Google OAuth client isn't shipped/configured yet (pre-verification)
|
||||
// needs_auth — client ready, but this user hasn't completed the one-click sign-in
|
||||
let extraEnv = {};
|
||||
if (cfg.auth === "google") {
|
||||
if (!googleClientConfigured()) {
|
||||
this.servers.set(id, {
|
||||
id, cfg, status: "needs_setup", tools: [],
|
||||
error: "Secure Google sign-in is coming soon — we're finishing setup. Nothing for you to do.",
|
||||
});
|
||||
console.error(`[connectd] ${id}: Google client not configured — needs_setup`);
|
||||
return;
|
||||
}
|
||||
const token = await getValidGoogleAccessToken(id);
|
||||
if (!token) {
|
||||
this.servers.set(id, { id, cfg, status: "needs_auth", tools: [], error: "sign-in required" });
|
||||
console.error(`[connectd] ${id}: no Google token — needs_auth`);
|
||||
return;
|
||||
}
|
||||
// Hand the access token to the Google MCP server via env. Which exact variable the chosen
|
||||
// server reads is a Phase-4 certification detail (see google-oauth-verification-checklist.md),
|
||||
// not a code change — we set the common names so swapping servers stays config-only.
|
||||
extraEnv = { GOOGLE_ACCESS_TOKEN: token, GOOGLE_OAUTH_ACCESS_TOKEN: token };
|
||||
}
|
||||
try {
|
||||
const transport = new StdioClientTransport({
|
||||
command: cfg.command,
|
||||
args: cfg.args ?? [],
|
||||
env: { ...process.env, ...(cfg.env ?? {}) },
|
||||
command: resolveRuntimeCommand(cfg.command),
|
||||
args: (cfg.args ?? []).map(resolveBundledArg),
|
||||
env: { ...process.env, ...(cfg.env ?? {}), ...extraEnv },
|
||||
});
|
||||
await this.finishConnect(id, cfg, transport);
|
||||
}
|
||||
catch (err) {
|
||||
// A missing runtime/command (e.g. npx not installed on a clean Mac for a not-yet-bundled
|
||||
// connector) surfaces as ENOENT. Show an honest "coming soon" state, never a cryptic spawn
|
||||
// error — the connector just isn't available on this device yet.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/ENOENT/.test(msg)) {
|
||||
this.servers.set(id, {
|
||||
id, cfg, status: "needs_setup", tools: [],
|
||||
error: "This connector isn't available on this device yet — we're finishing setup.",
|
||||
});
|
||||
console.error(`[connectd] ${id}: runtime/command unavailable (${msg}) — needs_setup`);
|
||||
return;
|
||||
}
|
||||
this.setError(id, cfg, err);
|
||||
}
|
||||
}
|
||||
@@ -246,6 +303,34 @@ export class ConnectorBridge {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
// Begin a Google one-click sign-in: returns the consent URL for the UI to open in the browser.
|
||||
async startGoogleOAuth(id) {
|
||||
const cfg = this.config.servers[id];
|
||||
if (!cfg)
|
||||
return { ok: false, error: `unknown server: ${id}` };
|
||||
if (cfg.auth !== "google")
|
||||
return { ok: false, error: `${id} is not a Google connector` };
|
||||
if (!googleClientConfigured())
|
||||
return { ok: false, error: "Google sign-in is being set up" };
|
||||
const url = buildGoogleAuthUrl(id, cfg.scopes ?? []);
|
||||
if (!url)
|
||||
return { ok: false, error: "could not build Google authorization URL" };
|
||||
return { ok: true, authUrl: url };
|
||||
}
|
||||
// Complete a Google sign-in with the code from the callback: store tokens, reconnect, list tools.
|
||||
async finishGoogleOAuth(id, code) {
|
||||
try {
|
||||
await exchangeGoogleCode(id, code);
|
||||
}
|
||||
catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
await this.reconnectServer(id);
|
||||
const now = this.servers.get(id);
|
||||
return now?.status === "connected"
|
||||
? { ok: true }
|
||||
: { ok: false, error: `post-auth status: ${now?.status} (${now?.error ?? ""})` };
|
||||
}
|
||||
// Merged, namespaced tool list across all connected servers.
|
||||
allTools() {
|
||||
const out = [];
|
||||
|
||||
Reference in New Issue
Block a user