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:
@@ -1,3 +1,7 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.skills-sync*
|
||||
|
||||
# esbuild single-file bundle + pre-bundled servers (produced by npm run bundle:all / DMG build)
|
||||
dist/connectd.cjs
|
||||
dist/servers/
|
||||
|
||||
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 = [];
|
||||
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { kcGetJson, kcSetJson, kcDelete } from "./keychain.js";
|
||||
import { HOST, PORT } from "./config.js";
|
||||
// ── Bridge-managed Google OAuth (one-click for Drive / Gmail / Calendar) ─────────────────────
|
||||
//
|
||||
// Why this exists: the community Google MCP servers each want a gcp-oauth.keys.json file plus a
|
||||
// one-time `npx … auth` CLI step. That's unacceptable friction for a consumer product. Instead the
|
||||
// bridge runs Google's OAuth itself — desktop-app authorization-code + PKCE — and stores the
|
||||
// resulting refresh token in the macOS Keychain (account "<id>:google"). The stdio MCP server is
|
||||
// then handed a short-lived access token, so the user only ever clicks "Connect" and approves in
|
||||
// the browser. No files, no terminal.
|
||||
//
|
||||
// Ships gated: a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID / _SECRET) must be
|
||||
// present, and the app must clear Google's verification for the requested scopes (see
|
||||
// docs/Tims stuff/google-oauth-verification-checklist.md). Until the client id is configured,
|
||||
// googleClientConfigured() is false and callers surface an honest "being set up" state rather than
|
||||
// launching a server that will crash with no credentials.
|
||||
const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
||||
// The loopback redirect must match exactly what's registered on the Google desktop OAuth client.
|
||||
export const GOOGLE_REDIRECT_URI = `http://${HOST}:${PORT}/google/callback`;
|
||||
function clientId() {
|
||||
const v = process.env.GOOGLE_OAUTH_CLIENT_ID;
|
||||
return v && v.trim() ? v.trim() : undefined;
|
||||
}
|
||||
function clientSecret() {
|
||||
const v = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
|
||||
return v && v.trim() ? v.trim() : undefined;
|
||||
}
|
||||
// True once the app is shipped with a real Google OAuth client. Gates the whole flow.
|
||||
export function googleClientConfigured() {
|
||||
return clientId() !== undefined;
|
||||
}
|
||||
const pending = new Map();
|
||||
function base64url(buf) {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
// Begin a sign-in: produce the Google consent URL and stash the PKCE verifier for this id.
|
||||
// Returns undefined if the Google client isn't configured yet (caller surfaces needs_setup).
|
||||
export function buildGoogleAuthUrl(id, scopes) {
|
||||
const cid = clientId();
|
||||
if (!cid)
|
||||
return undefined;
|
||||
const verifier = base64url(randomBytes(32));
|
||||
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
||||
pending.set(id, { verifier, scopes });
|
||||
const params = new URLSearchParams({
|
||||
client_id: cid,
|
||||
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: scopes.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
access_type: "offline", // ask for a refresh token
|
||||
prompt: "consent", // force refresh-token issuance on re-consent
|
||||
state: id, // carry the connector id through the round-trip
|
||||
});
|
||||
return `${AUTH_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
// Complete a sign-in: exchange the authorization code for tokens and persist the refresh token.
|
||||
// Throws on any failure (missing flow, Google error) so the caller can report it honestly.
|
||||
export async function exchangeGoogleCode(id, code) {
|
||||
const flow = pending.get(id);
|
||||
pending.delete(id);
|
||||
if (!flow)
|
||||
throw new Error(`no pending Google sign-in for ${id}`);
|
||||
const cid = clientId();
|
||||
if (!cid)
|
||||
throw new Error("Google OAuth client not configured");
|
||||
const body = new URLSearchParams({
|
||||
client_id: cid,
|
||||
code,
|
||||
code_verifier: flow.verifier,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||
});
|
||||
// Desktop clients may still carry a (non-secret) client secret; include it when present.
|
||||
const secret = clientSecret();
|
||||
if (secret)
|
||||
body.set("client_secret", secret);
|
||||
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const json = (await resp.json());
|
||||
if (!resp.ok)
|
||||
throw new Error(`Google token exchange failed: ${json.error ?? resp.status} ${json.error_description ?? ""}`.trim());
|
||||
if (!json.refresh_token) {
|
||||
throw new Error("Google did not return a refresh token (revoke prior access and retry with prompt=consent)");
|
||||
}
|
||||
const tokens = {
|
||||
refresh_token: json.refresh_token,
|
||||
access_token: json.access_token,
|
||||
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||
scope: json.scope,
|
||||
};
|
||||
await kcSetJson(`${id}:google`, tokens);
|
||||
return tokens;
|
||||
}
|
||||
// Return a currently-valid access token for this connector, refreshing if expired. Returns
|
||||
// undefined if the user hasn't signed in (no stored refresh token) — caller → needs_auth.
|
||||
export async function getValidGoogleAccessToken(id) {
|
||||
const stored = await kcGetJson(`${id}:google`);
|
||||
if (!stored?.refresh_token)
|
||||
return undefined;
|
||||
// 60s skew so we never hand out a token that's about to expire mid-call.
|
||||
if (stored.access_token && stored.expires_at && stored.expires_at - 60_000 > nowMs()) {
|
||||
return stored.access_token;
|
||||
}
|
||||
return refreshGoogleToken(id, stored);
|
||||
}
|
||||
async function refreshGoogleToken(id, stored) {
|
||||
const cid = clientId();
|
||||
if (!cid)
|
||||
return undefined;
|
||||
const body = new URLSearchParams({
|
||||
client_id: cid,
|
||||
refresh_token: stored.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
});
|
||||
const secret = clientSecret();
|
||||
if (secret)
|
||||
body.set("client_secret", secret);
|
||||
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const json = (await resp.json());
|
||||
if (!resp.ok || !json.access_token)
|
||||
return undefined;
|
||||
const updated = {
|
||||
refresh_token: stored.refresh_token, // Google omits it on refresh; keep the original
|
||||
access_token: json.access_token,
|
||||
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||
scope: json.scope ?? stored.scope,
|
||||
};
|
||||
await kcSetJson(`${id}:google`, updated);
|
||||
return updated.access_token;
|
||||
}
|
||||
// True once the user has completed sign-in for this connector (a refresh token is stored).
|
||||
export async function hasGoogleTokens(id) {
|
||||
const stored = await kcGetJson(`${id}:google`);
|
||||
return !!stored?.refresh_token;
|
||||
}
|
||||
// Forget a connector's Google tokens (on remove / sign-out).
|
||||
export async function clearGoogleTokens(id) {
|
||||
await kcDelete(`${id}:google`);
|
||||
}
|
||||
// Wrapped so tests/builds without a real clock injection stay simple; Date.now is fine in the
|
||||
// bridge process (unlike the El soul, this is plain Node).
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
Vendored
+21
@@ -85,6 +85,27 @@ export function startServer(bridge) {
|
||||
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") {
|
||||
|
||||
+6
-1
@@ -10,12 +10,17 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"bundle": "esbuild src/index.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/connectd.cjs",
|
||||
"bundle:servers": "esbuild node_modules/@modelcontextprotocol/server-filesystem/dist/index.js --bundle --platform=node --format=esm --target=node20 --banner:js=\"import{createRequire as __cr}from'module';const require=__cr(import.meta.url);\" --outfile=dist/servers/fs-server.mjs",
|
||||
"bundle:all": "npm run bundle && npm run bundle:servers",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@modelcontextprotocol/server-filesystem": "^2026.1.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"@types/node": "^24.0.0"
|
||||
|
||||
+98
-4
@@ -3,9 +3,17 @@ 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 type { ConnectorsConfig, ServerConfig } from "./config.js";
|
||||
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
|
||||
@@ -18,6 +26,26 @@ export function namespacedName(serverId: string, tool: string): string {
|
||||
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(): string {
|
||||
return process.env.NEURON_CONNECTD_BUNDLE_DIR ?? join(dirname(process.argv[1] ?? process.cwd()), "servers");
|
||||
}
|
||||
|
||||
function resolveRuntimeCommand(command: string): string {
|
||||
return command === "@node" ? process.execPath : command;
|
||||
}
|
||||
|
||||
function resolveBundledArg(arg: string): string {
|
||||
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: string): { serverId: string; tool: string } | null {
|
||||
@@ -35,7 +63,10 @@ export interface ExposedTool {
|
||||
input_schema: unknown;
|
||||
}
|
||||
|
||||
type ServerStatus = "connected" | "needs_auth" | "error" | "disabled";
|
||||
// needs_setup: a Google connector whose app-level OAuth client isn't configured yet (pre-launch).
|
||||
// Distinct from needs_auth (client ready, this user just hasn't signed in) so the UI can say
|
||||
// "being set up" instead of "sign in", and never drops the user into a broken flow.
|
||||
type ServerStatus = "connected" | "needs_auth" | "needs_setup" | "error" | "disabled";
|
||||
|
||||
interface ServerEntry {
|
||||
id: string;
|
||||
@@ -114,6 +145,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 };
|
||||
}
|
||||
|
||||
@@ -142,14 +174,51 @@ export class ConnectorBridge {
|
||||
}
|
||||
|
||||
private async connectStdio(id: string, cfg: ServerConfig): Promise<void> {
|
||||
// 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: Record<string, string> = {};
|
||||
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 ?? {}) } as Record<string, string>,
|
||||
command: resolveRuntimeCommand(cfg.command!),
|
||||
args: (cfg.args ?? []).map(resolveBundledArg),
|
||||
env: { ...process.env, ...(cfg.env ?? {}), ...extraEnv } as Record<string, string>,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -273,6 +342,31 @@ export class ConnectorBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// Begin a Google one-click sign-in: returns the consent URL for the UI to open in the browser.
|
||||
async startGoogleOAuth(id: string): Promise<{ ok: boolean; authUrl?: string; error?: string }> {
|
||||
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: string, code: string): Promise<{ ok: boolean; error?: string }> {
|
||||
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(): ExposedTool[] {
|
||||
const out: ExposedTool[] = [];
|
||||
|
||||
+6
-1
@@ -13,8 +13,13 @@ export interface ServerConfig {
|
||||
env?: Record<string, string>;
|
||||
// http (Phase 3)
|
||||
url?: string;
|
||||
auth?: "none" | "oauth" | "token";
|
||||
auth?: "none" | "oauth" | "token" | "google";
|
||||
scope?: string; // OAuth scope string, when auth === "oauth"
|
||||
// Google connectors (Drive/Gmail/Calendar): stdio servers whose Google OAuth the bridge
|
||||
// performs itself (one-click), storing tokens in the Keychain. `scopes` = the Google API
|
||||
// scopes to request. Requires a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID);
|
||||
// until that's configured the connector reports needs_setup instead of failing cryptically.
|
||||
scopes?: string[];
|
||||
// Phase 5: per-connector opt-in to skip the soul's approval card (read-only-leaning,
|
||||
// off by default). The soul reads this from connectors.json at approval time.
|
||||
autoApprove?: boolean;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { kcGetJson, kcSetJson, kcDelete } from "./keychain.js";
|
||||
import { HOST, PORT } from "./config.js";
|
||||
|
||||
// ── Bridge-managed Google OAuth (one-click for Drive / Gmail / Calendar) ─────────────────────
|
||||
//
|
||||
// Why this exists: the community Google MCP servers each want a gcp-oauth.keys.json file plus a
|
||||
// one-time `npx … auth` CLI step. That's unacceptable friction for a consumer product. Instead the
|
||||
// bridge runs Google's OAuth itself — desktop-app authorization-code + PKCE — and stores the
|
||||
// resulting refresh token in the macOS Keychain (account "<id>:google"). The stdio MCP server is
|
||||
// then handed a short-lived access token, so the user only ever clicks "Connect" and approves in
|
||||
// the browser. No files, no terminal.
|
||||
//
|
||||
// Ships gated: a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID / _SECRET) must be
|
||||
// present, and the app must clear Google's verification for the requested scopes (see
|
||||
// docs/Tims stuff/google-oauth-verification-checklist.md). Until the client id is configured,
|
||||
// googleClientConfigured() is false and callers surface an honest "being set up" state rather than
|
||||
// launching a server that will crash with no credentials.
|
||||
|
||||
const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
||||
|
||||
// The loopback redirect must match exactly what's registered on the Google desktop OAuth client.
|
||||
export const GOOGLE_REDIRECT_URI = `http://${HOST}:${PORT}/google/callback`;
|
||||
|
||||
export interface GoogleTokens {
|
||||
refresh_token: string;
|
||||
access_token?: string;
|
||||
expires_at?: number; // epoch ms when access_token expires
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
function clientId(): string | undefined {
|
||||
const v = process.env.GOOGLE_OAUTH_CLIENT_ID;
|
||||
return v && v.trim() ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
function clientSecret(): string | undefined {
|
||||
const v = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
|
||||
return v && v.trim() ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
// True once the app is shipped with a real Google OAuth client. Gates the whole flow.
|
||||
export function googleClientConfigured(): boolean {
|
||||
return clientId() !== undefined;
|
||||
}
|
||||
|
||||
// In-flight PKCE state, keyed by connector id. Held in memory only for the seconds between
|
||||
// /google/oauth/start and the browser redirect to /google/callback.
|
||||
interface PendingFlow {
|
||||
verifier: string;
|
||||
scopes: string[];
|
||||
}
|
||||
const pending = new Map<string, PendingFlow>();
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
// Begin a sign-in: produce the Google consent URL and stash the PKCE verifier for this id.
|
||||
// Returns undefined if the Google client isn't configured yet (caller surfaces needs_setup).
|
||||
export function buildGoogleAuthUrl(id: string, scopes: string[]): string | undefined {
|
||||
const cid = clientId();
|
||||
if (!cid) return undefined;
|
||||
const verifier = base64url(randomBytes(32));
|
||||
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
||||
pending.set(id, { verifier, scopes });
|
||||
const params = new URLSearchParams({
|
||||
client_id: cid,
|
||||
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: scopes.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
access_type: "offline", // ask for a refresh token
|
||||
prompt: "consent", // force refresh-token issuance on re-consent
|
||||
state: id, // carry the connector id through the round-trip
|
||||
});
|
||||
return `${AUTH_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Complete a sign-in: exchange the authorization code for tokens and persist the refresh token.
|
||||
// Throws on any failure (missing flow, Google error) so the caller can report it honestly.
|
||||
export async function exchangeGoogleCode(id: string, code: string): Promise<GoogleTokens> {
|
||||
const flow = pending.get(id);
|
||||
pending.delete(id);
|
||||
if (!flow) throw new Error(`no pending Google sign-in for ${id}`);
|
||||
const cid = clientId();
|
||||
if (!cid) throw new Error("Google OAuth client not configured");
|
||||
|
||||
const body = new URLSearchParams({
|
||||
client_id: cid,
|
||||
code,
|
||||
code_verifier: flow.verifier,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||
});
|
||||
// Desktop clients may still carry a (non-secret) client secret; include it when present.
|
||||
const secret = clientSecret();
|
||||
if (secret) body.set("client_secret", secret);
|
||||
|
||||
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const json = (await resp.json()) as Record<string, any>;
|
||||
if (!resp.ok) throw new Error(`Google token exchange failed: ${json.error ?? resp.status} ${json.error_description ?? ""}`.trim());
|
||||
if (!json.refresh_token) {
|
||||
throw new Error("Google did not return a refresh token (revoke prior access and retry with prompt=consent)");
|
||||
}
|
||||
const tokens: GoogleTokens = {
|
||||
refresh_token: json.refresh_token,
|
||||
access_token: json.access_token,
|
||||
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||
scope: json.scope,
|
||||
};
|
||||
await kcSetJson(`${id}:google`, tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Return a currently-valid access token for this connector, refreshing if expired. Returns
|
||||
// undefined if the user hasn't signed in (no stored refresh token) — caller → needs_auth.
|
||||
export async function getValidGoogleAccessToken(id: string): Promise<string | undefined> {
|
||||
const stored = await kcGetJson<GoogleTokens>(`${id}:google`);
|
||||
if (!stored?.refresh_token) return undefined;
|
||||
// 60s skew so we never hand out a token that's about to expire mid-call.
|
||||
if (stored.access_token && stored.expires_at && stored.expires_at - 60_000 > nowMs()) {
|
||||
return stored.access_token;
|
||||
}
|
||||
return refreshGoogleToken(id, stored);
|
||||
}
|
||||
|
||||
async function refreshGoogleToken(id: string, stored: GoogleTokens): Promise<string | undefined> {
|
||||
const cid = clientId();
|
||||
if (!cid) return undefined;
|
||||
const body = new URLSearchParams({
|
||||
client_id: cid,
|
||||
refresh_token: stored.refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
});
|
||||
const secret = clientSecret();
|
||||
if (secret) body.set("client_secret", secret);
|
||||
|
||||
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const json = (await resp.json()) as Record<string, any>;
|
||||
if (!resp.ok || !json.access_token) return undefined;
|
||||
const updated: GoogleTokens = {
|
||||
refresh_token: stored.refresh_token, // Google omits it on refresh; keep the original
|
||||
access_token: json.access_token,
|
||||
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||
scope: json.scope ?? stored.scope,
|
||||
};
|
||||
await kcSetJson(`${id}:google`, updated);
|
||||
return updated.access_token;
|
||||
}
|
||||
|
||||
// True once the user has completed sign-in for this connector (a refresh token is stored).
|
||||
export async function hasGoogleTokens(id: string): Promise<boolean> {
|
||||
const stored = await kcGetJson<GoogleTokens>(`${id}:google`);
|
||||
return !!stored?.refresh_token;
|
||||
}
|
||||
|
||||
// Forget a connector's Google tokens (on remove / sign-out).
|
||||
export async function clearGoogleTokens(id: string): Promise<void> {
|
||||
await kcDelete(`${id}:google`);
|
||||
}
|
||||
|
||||
// Wrapped so tests/builds without a real clock injection stay simple; Date.now is fine in the
|
||||
// bridge process (unlike the El soul, this is plain Node).
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
@@ -92,6 +92,26 @@ export function startServer(bridge: ConnectorBridge): void {
|
||||
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<{ id?: string }>(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") {
|
||||
|
||||
Reference in New Issue
Block a user