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:
Tim Lingo
2026-06-26 14:27:04 -05:00
parent fdf8fb5cda
commit a01236753a
9 changed files with 575 additions and 9 deletions
+4
View File
@@ -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/
+88 -3
View File
@@ -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 = [];
+155
View File
@@ -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();
}
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+177
View File
@@ -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();
}
+20
View File
@@ -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") {