From a01236753a44a25b75fd79acc616f9975303becf Mon Sep 17 00:00:00 2001 From: Tim Lingo <1timlingo@gmail.com> Date: Fri, 26 Jun 2026 14:27:04 -0500 Subject: [PATCH] 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/" (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) --- .gitignore | 4 + dist/bridge.js | 91 +++++++++++++++++++++- dist/google-oauth.js | 155 +++++++++++++++++++++++++++++++++++++ dist/server.js | 21 +++++ package.json | 7 +- src/bridge.ts | 102 ++++++++++++++++++++++++- src/config.ts | 7 +- src/google-oauth.ts | 177 +++++++++++++++++++++++++++++++++++++++++++ src/server.ts | 20 +++++ 9 files changed, 575 insertions(+), 9 deletions(-) create mode 100644 dist/google-oauth.js create mode 100644 src/google-oauth.ts diff --git a/.gitignore b/.gitignore index 659dc09..e1d7640 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/dist/bridge.js b/dist/bridge.js index 78065c6..3daa580 100644 --- a/dist/bridge.js +++ b/dist/bridge.js @@ -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____ @@ -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/" → 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 = []; diff --git a/dist/google-oauth.js b/dist/google-oauth.js new file mode 100644 index 0000000..50c8cfd --- /dev/null +++ b/dist/google-oauth.js @@ -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 ":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(); +} diff --git a/dist/server.js b/dist/server.js index c566672..81263c8 100644 --- a/dist/server.js +++ b/dist/server.js @@ -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") { diff --git a/package.json b/package.json index a36e7f0..87051aa 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/bridge.ts b/src/bridge.ts index b7921c8..3a88179 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -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/" → 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 { + // 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 = {}; + 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, + command: resolveRuntimeCommand(cfg.command!), + args: (cfg.args ?? []).map(resolveBundledArg), + env: { ...process.env, ...(cfg.env ?? {}), ...extraEnv } as Record, }); 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[] = []; diff --git a/src/config.ts b/src/config.ts index a16cc51..fe2f39c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,8 +13,13 @@ export interface ServerConfig { env?: Record; // 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; diff --git a/src/google-oauth.ts b/src/google-oauth.ts new file mode 100644 index 0000000..73ce4fe --- /dev/null +++ b/src/google-oauth.ts @@ -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 ":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(); + +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 { + 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; + 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 { + 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: string, stored: GoogleTokens): Promise { + 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; + 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 { + 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: string): Promise { + 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(); +} diff --git a/src/server.ts b/src/server.ts index 6e18bd0..fa18dd6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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") {