From 0ec44faaf746cb3980d1e63b327d4b9d995ddfda Mon Sep 17 00:00:00 2001 From: meidad Date: Tue, 26 May 2026 17:22:49 -0700 Subject: [PATCH] feat(google): multi-account Gmail/Calendar via in-process MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the broken `gws mcp` external server (the subcommand was removed from @googleworkspace/cli v0.22.5) with an in-process MCP that wraps gws CLI calls. The wrapper exposes typed gmail_* / calendar_* tools to the agent and adds native multi-account support along the way. Multi-account follows the convention from indentcorp/gws-multi-account (see googleworkspace/cli#78): each account lives in ~/.config/gws// with its own client_secret.json + credentials.enc, and gws is invoked with GOOGLE_WORKSPACE_CLI_CONFIG_DIR pointing at the right dir. ~/.config/gws/accounts.json is the manifest; the integrations DB row is just a cache. What's new: - src/auth/gws-accounts.ts — manifest CRUD + per-account runGws/runGwsJson helper that injects the env var - src/sdk/google-workspace-mcp.ts — in-process MCP with 12 tools: gmail_search, gmail_get_message, gmail_get_thread, gmail_create_draft, gmail_send_draft, gmail_list_labels, calendar_list_events, calendar_get_event, calendar_create_event, calendar_update_event, calendar_delete_event, google_list_accounts. Each takes an optional `account` arg (defaults to the manifest's default). - Settings UI gains "Add another account" + per-account "Make default" - OAuth start route now runs each auth attempt in a pending dir, then resolves the email from the granted token and promotes the dir to its final ~/.config/gws// home. Multiple accounts coexist; re-auth atomically replaces just that one. - Ingest: gmail.ts iterates every account in the manifest by default; `nomos ingest gmail --account ` scopes to one. Also rolls in two fixes from earlier in this session that hadn't landed yet: - gmail.ts now passes `--unmasked` to `gws auth export` (without it, gws returns truncated client_secret/refresh_token that fail token refresh with `invalid_client`). - embeddings.ts honors Gemini's `retryDelay` on 429 free-tier quota hits instead of bailing on the whole ingest. Legacy single-account installs keep working: the manifest is empty until the first multi-account auth migrates them, and every code path falls back to the original single-account behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- settings/src/app/api/google/accounts/route.ts | 107 ++- .../src/app/api/google/oauth/start/route.ts | 190 ++++- settings/src/app/api/google/status/route.ts | 87 +- settings/src/app/api/google/test/route.ts | 29 + settings/src/app/integrations/google/page.tsx | 70 +- settings/src/lib/gws-accounts.ts | 175 ++++ settings/src/lib/sync-google-accounts.ts | 62 +- src/auth/gws-accounts.ts | 265 ++++++ src/cli/chat.ts | 4 +- src/cli/ingest.ts | 18 +- src/daemon/agent-runtime.ts | 17 +- src/db/google-accounts.ts | 61 +- src/ingest/sources/gmail.ts | 77 +- src/ingest/types.ts | 6 + src/memory/embeddings.ts | 100 ++- src/sdk/google-workspace-mcp.ts | 792 ++++++++++++++---- 16 files changed, 1704 insertions(+), 356 deletions(-) create mode 100644 settings/src/lib/gws-accounts.ts create mode 100644 src/auth/gws-accounts.ts diff --git a/settings/src/app/api/google/accounts/route.ts b/settings/src/app/api/google/accounts/route.ts index 325de75..36cb1c3 100644 --- a/settings/src/app/api/google/accounts/route.ts +++ b/settings/src/app/api/google/accounts/route.ts @@ -8,21 +8,34 @@ const execFileAsync = promisify(execFile); export async function GET() { const accounts: Array<{ email: string; default: boolean }> = []; - // Read accounts from DB (integrations table, google-ws:* naming) + // Primary source: the on-disk multi-account manifest. try { - const sql = getDb(); - const rows = await sql` - SELECT name, metadata FROM integrations - WHERE name LIKE 'google-ws:%' AND enabled = true - ORDER BY metadata->>'is_default' DESC, name - `; - for (const row of rows) { - const email = (row.name as string).replace(/^google-ws:/, ""); - const meta = row.metadata as Record; - accounts.push({ email, default: !!meta?.is_default }); + const { listAccounts } = await import("@/lib/gws-accounts"); + for (const a of listAccounts()) { + accounts.push({ email: a.email, default: a.isDefault }); } } catch { - // DB not available + // helper unavailable + } + + // Fallback: DB rows (used by legacy single-account installs whose + // manifest hasn't been populated yet). + if (accounts.length === 0) { + try { + const sql = getDb(); + const rows = await sql` + SELECT name, metadata FROM integrations + WHERE name LIKE 'google-ws:%' AND enabled = true + ORDER BY metadata->>'is_default' DESC, name + `; + for (const row of rows) { + const email = (row.name as string).replace(/^google-ws:/, ""); + const meta = row.metadata as Record; + accounts.push({ email, default: !!meta?.is_default }); + } + } catch { + // DB not available + } } // Also check gws auth status for the currently authenticated account @@ -105,11 +118,30 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: "Email is required" }, { status: 400 }); } - // Logout from gws (v0.22.5+ is single-account, no --account flag) + // Multi-account path: remove the per-account dir + manifest entry. This + // also nukes the gws-stored refresh_token for the account, so no need + // to call `gws auth logout` separately. try { - await execFileAsync("npx", ["@googleworkspace/cli", "auth", "logout"], { timeout: 10000 }); + const { listAccounts, removeAccountFromManifest } = await import("@/lib/gws-accounts"); + if (listAccounts().find((a) => a.email === email)) { + removeAccountFromManifest(email); + } else { + // Legacy single-account install — fall back to global logout. + try { + await execFileAsync("npx", ["@googleworkspace/cli", "auth", "logout"], { + timeout: 10_000, + }); + } catch { + // Token may already be invalid + } + } } catch { - // Token may already be invalid + // gws-accounts helper unavailable — fall back to global logout. + try { + await execFileAsync("npx", ["@googleworkspace/cli", "auth", "logout"], { timeout: 10_000 }); + } catch { + // Token may already be invalid + } } // Remove from DB @@ -123,3 +155,48 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ ok: true }); } + +/** PATCH /api/google/accounts — set the default account. */ +export async function PATCH(request: NextRequest) { + let body: { email?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const email = body.email?.trim(); + if (!email) { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + try { + const { setDefaultAccount } = await import("@/lib/gws-accounts"); + setDefaultAccount(email); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 400 }); + } + + // Mirror the change into the DB so other surfaces see it immediately. + try { + const sql = getDb(); + await sql` + UPDATE integrations + SET metadata = jsonb_set(coalesce(metadata, '{}'::jsonb), '{is_default}', 'false'::jsonb), + updated_at = now() + WHERE name LIKE 'google-ws:%' + `; + const name = `google-ws:${email}`; + await sql` + UPDATE integrations + SET metadata = jsonb_set(coalesce(metadata, '{}'::jsonb), '{is_default}', 'true'::jsonb), + updated_at = now() + WHERE name = ${name} + `; + } catch { + // Non-blocking — manifest is the source of truth. + } + + return NextResponse.json({ ok: true }); +} diff --git a/settings/src/app/api/google/oauth/start/route.ts b/settings/src/app/api/google/oauth/start/route.ts index 803bc08..ce24541 100644 --- a/settings/src/app/api/google/oauth/start/route.ts +++ b/settings/src/app/api/google/oauth/start/route.ts @@ -2,10 +2,24 @@ import { NextResponse } from "next/server"; import { readConfig } from "@/lib/env"; import { getDb } from "@/lib/db"; import { syncGoogleAccountsToDb } from "@/lib/sync-google-accounts"; +import { + pendingAuthDir, + newPendingToken, + writeClientSecretToDir, + promotePendingDir, + cleanupPendingDir, +} from "@/lib/gws-accounts"; import { spawn, type ChildProcess } from "node:child_process"; -// Keep the child process alive at module level so it can receive the OAuth callback -let activeChild: ChildProcess | null = null; +// Keep the child process alive at module level so it can receive the OAuth +// callback (which fires asynchronously after the user finishes the browser +// flow). We also stash the pending token on the child so the exit handler +// can promote the working dir to its final per-account home. +interface ActiveAuth { + child: ChildProcess; + pendingToken: string; +} +let active: ActiveAuth | null = null; let killTimer: ReturnType | null = null; function cleanup() { @@ -13,9 +27,10 @@ function cleanup() { clearTimeout(killTimer); killTimer = null; } - if (activeChild) { - activeChild.kill(); - activeChild = null; + if (active) { + active.child.kill(); + cleanupPendingDir(active.pendingToken); + active = null; } } @@ -45,20 +60,22 @@ export async function POST() { // Clean up any previous auth process cleanup(); - // Write a valid client_secret.json (with project_id) for the CLI flow. - // Same helper /api/env uses on save, so the two paths can't drift. - const { writeGwsClientSecret } = await import("@/lib/sync-gws-client-secret"); - writeGwsClientSecret({ + // Each auth run gets its own pending working dir under + // `~/.config/gws/.pending-/`. We don't know the email yet — it + // comes back with the OAuth token — so we keep the dir anonymous until + // success, then rename to `~/.config/gws//` and register in the + // manifest. This works for both first-time auth and re-auth. + const pendingToken = newPendingToken(); + const pendingDir = pendingAuthDir(pendingToken); + writeClientSecretToDir(pendingDir, { clientId, clientSecret, projectId: gcpProjectId ?? "", }); - // Build args for gws auth login with explicit scopes. - // Using --scopes ensures Gmail/Calendar are included even if the gws CLI - // doesn't map service names to scopes correctly. - // All Google Workspace scopes -- pass explicitly since the gws CLI's - // -s flag doesn't reliably map service names to OAuth scopes. + // Build args for `gws auth login`. We pass all scopes we'll ever need + // — the OAuth consent screen in GCP must already include them; otherwise + // Google silently drops the un-registered ones at request time. const ALL_SCOPES = [ "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send", @@ -75,75 +92,81 @@ export async function POST() { ].join(","); const args = ["@googleworkspace/cli", "auth", "login", "--scopes", ALL_SCOPES]; - // Spawn gws auth login with piped stdout/stderr so we can capture the OAuth URL try { const child = spawn("npx", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, + // CRITICAL: scope this gws invocation to the pending dir so it + // doesn't blow away any other account already authorized. + GOOGLE_WORKSPACE_CLI_CONFIG_DIR: pendingDir, GOOGLE_WORKSPACE_CLI_CLIENT_ID: clientId, GOOGLE_WORKSPACE_CLI_CLIENT_SECRET: clientSecret, }, }); - activeChild = child; + active = { child, pendingToken }; // Kill the process after 120s if auth isn't completed killTimer = setTimeout(() => { - if (activeChild === child) { + if (active?.child === child) { child.kill(); - activeChild = null; + cleanupPendingDir(pendingToken); + active = null; } killTimer = null; }, 120_000); - // Clean up references when the process exits naturally - child.on("exit", (code) => { - if (activeChild === child) { - activeChild = null; - } + // On exit code 0: resolve the email from the granted token, then move + // the pending dir to ~/.config/gws// and register it in the + // manifest. The Settings UI polls /api/google/status to detect this. + child.on("exit", async (code) => { + if (active?.child === child) active = null; if (killTimer) { clearTimeout(killTimer); killTimer = null; } - // Sync accounts to DB after successful OAuth - if (code === 0) { - syncGoogleAccountsToDb().catch(() => {}); + if (code !== 0) { + cleanupPendingDir(pendingToken); + return; + } + + try { + const email = await resolveEmailFromPendingDir(pendingDir); + if (!email) { + cleanupPendingDir(pendingToken); + return; + } + promotePendingDir(pendingToken, email); + await syncGoogleAccountsToDb().catch(() => {}); + } catch (err) { + console.error("[oauth/start] Failed to finalize auth:", err); + cleanupPendingDir(pendingToken); } }); - // Read stdout to find the OAuth URL + // Read stdout/stderr to find the OAuth URL gws prints. const url = await new Promise((resolve) => { let output = ""; const urlPattern = /https:\/\/accounts\.google\.com\/o\/oauth2\/auth\S+/; - // Timeout if we don't get the URL within 15 seconds const timeout = setTimeout(() => resolve(null), 15_000); - child.stdout!.on("data", (chunk: Buffer) => { - output += chunk.toString(); - const match = output.match(urlPattern); - if (match) { - clearTimeout(timeout); - resolve(match[0]); - } - }); - - child.stderr!.on("data", (chunk: Buffer) => { + const handleChunk = (chunk: Buffer) => { output += chunk.toString(); - // Some CLIs print the URL to stderr const match = output.match(urlPattern); if (match) { clearTimeout(timeout); resolve(match[0]); } - }); + }; + child.stdout?.on("data", handleChunk); + child.stderr?.on("data", handleChunk); child.on("error", () => { clearTimeout(timeout); resolve(null); }); - child.on("exit", (code) => { clearTimeout(timeout); if (code !== 0) resolve(null); @@ -161,10 +184,10 @@ export async function POST() { ); } - // Inject openid+email scopes and force consent prompt. - // prompt=consent is required for Google Workspace accounts with - // re-authentication policies (RAPT) -- without it, token exchange - // fails with invalid_rapt even on fresh logins. + // Inject openid+email scopes and force consent prompt. prompt=consent + // is required for Google Workspace accounts with re-authentication + // policies (RAPT) — without it, token exchange fails with invalid_rapt + // even on fresh logins. let authUrl = url; try { const parsed = new URL(authUrl); @@ -179,10 +202,85 @@ export async function POST() { // If URL parsing fails, use the original } - return NextResponse.json({ ok: true, url: authUrl }); + return NextResponse.json({ ok: true, url: authUrl, pendingToken }); } catch (err) { cleanup(); const message = err instanceof Error ? err.message : String(err); return NextResponse.json({ error: `Failed to start gws auth: ${message}` }, { status: 500 }); } } + +/** + * Pull the just-granted refresh token out of the pending dir, exchange it + * for an access token, then hit Google's userinfo endpoint to resolve the + * email. Returns null if any step fails (the dir is left intact for the + * caller to clean up). + */ +async function resolveEmailFromPendingDir(dir: string): Promise { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execFileAsync = promisify(execFile); + + let creds: { client_id: string; client_secret: string; refresh_token: string }; + try { + const { stdout } = await execFileAsync( + "npx", + ["@googleworkspace/cli", "auth", "export", "--unmasked"], + { + timeout: 10_000, + env: { ...process.env, GOOGLE_WORKSPACE_CLI_CONFIG_DIR: dir }, + }, + ); + const jsonStart = stdout.search(/\{/); + if (jsonStart < 0) return null; + creds = JSON.parse(stdout.slice(jsonStart)); + } catch (err) { + console.error("[oauth/start] auth export failed:", err); + return null; + } + + if (!creds.refresh_token || !creds.client_id || !creds.client_secret) return null; + + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: creds.client_id, + client_secret: creds.client_secret, + refresh_token: creds.refresh_token, + grant_type: "refresh_token", + }), + }); + if (!tokenRes.ok) return null; + + const tokenData = (await tokenRes.json()) as { access_token?: string }; + if (!tokenData.access_token) return null; + + // Try userinfo first (works if openid scope was granted). + try { + const userRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + if (userRes.ok) { + const info = (await userRes.json()) as { email?: string }; + if (info.email) return info.email; + } + } catch { + // openid not granted — fall through to Gmail profile. + } + + // Fallback: Gmail profile (works if Gmail scope was granted). + try { + const profileRes = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + if (profileRes.ok) { + const profile = (await profileRes.json()) as { emailAddress?: string }; + if (profile.emailAddress) return profile.emailAddress; + } + } catch { + // Gmail scope not granted either — give up. + } + + return null; +} diff --git a/settings/src/app/api/google/status/route.ts b/settings/src/app/api/google/status/route.ts index 96f7029..ced8605 100644 --- a/settings/src/app/api/google/status/route.ts +++ b/settings/src/app/api/google/status/route.ts @@ -55,57 +55,79 @@ export async function GET() { } } - // Check gws auth status for token validity + // Verify the gws-stored credentials can actually mint a fresh access + // token. `auth status` only reports whether credentials exist in the + // keyring — it returns "authenticated" even when the refresh_token is + // tied to a different OAuth client than what's now on disk (in which + // case Google rejects refresh with `invalid_client`). + let tokenError: string | null = null; if (gwsInstalled) { try { const { stdout } = await execFileAsync("npx", ["@googleworkspace/cli", "auth", "status"], { timeout: 10000, }); const status = JSON.parse(stdout); - if (status.auth_method !== "none" || status.token_cache_exists || status.storage !== "none") { - hasValidToken = true; + const hasCredsInKeyring = + status.auth_method !== "none" || status.token_cache_exists || status.storage !== "none"; - // If no accounts in DB, try to resolve email from token - if (accounts.length === 0) { - try { - const { stdout: exportOut } = await execFileAsync( - "npx", - ["@googleworkspace/cli", "auth", "export", "--unmasked"], - { timeout: 10000 }, - ); - const creds = JSON.parse(exportOut); - if (creds.refresh_token && creds.client_id && creds.client_secret) { - const tokenRes = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: creds.client_id, - client_secret: creds.client_secret, - refresh_token: creds.refresh_token, - grant_type: "refresh_token", - }), - }); - if (tokenRes.ok) { - const tokenData = await tokenRes.json(); - // Try userinfo + if (hasCredsInKeyring) { + try { + const { stdout: exportOut } = await execFileAsync( + "npx", + ["@googleworkspace/cli", "auth", "export", "--unmasked"], + { timeout: 10000 }, + ); + const creds = JSON.parse(exportOut); + if (creds.refresh_token && creds.client_id && creds.client_secret) { + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: creds.client_id, + client_secret: creds.client_secret, + refresh_token: creds.refresh_token, + grant_type: "refresh_token", + }), + }); + + if (tokenRes.ok) { + hasValidToken = true; + const tokenData = await tokenRes.json(); + // If no accounts in DB yet, resolve email from userinfo. + if (accounts.length === 0) { try { const userRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); if (userRes.ok) { const info = await userRes.json(); - if (info.email) { - accounts.push({ email: info.email, default: true }); - } + if (info.email) accounts.push({ email: info.email, default: true }); } } catch { - // Scope not available + // openid scope may not be granted; non-fatal } } + } else { + // Refresh failed — parse Google's error response for an + // actionable reason. Common cases: + // invalid_client: refresh_token tied to a different + // OAuth client than the one in client_secret.json + // (usually after credentials were rotated/rewritten). + // invalid_grant: refresh token expired or revoked. + let oauthError: string | null = null; + try { + const errJson = await tokenRes.json(); + oauthError = errJson?.error ?? null; + } catch { + // ignore + } + tokenError = oauthError ?? `HTTP ${tokenRes.status} from Google token endpoint`; } - } catch { - // Could not resolve email + } else { + tokenError = "gws keyring is missing refresh_token or client credentials"; } + } catch (err) { + tokenError = err instanceof Error ? err.message : String(err); } } } catch { @@ -124,6 +146,7 @@ export async function GET() { gwsVersion, accounts, hasValidToken, + tokenError, services, clientId: !!env.GOOGLE_OAUTH_CLIENT_ID, clientSecret: !!env.GOOGLE_OAUTH_CLIENT_SECRET, diff --git a/settings/src/app/api/google/test/route.ts b/settings/src/app/api/google/test/route.ts index 9a4a1f4..8671a99 100644 --- a/settings/src/app/api/google/test/route.ts +++ b/settings/src/app/api/google/test/route.ts @@ -56,12 +56,41 @@ export async function POST() { }); } catch (apiErr) { const errMsg = apiErr instanceof Error ? apiErr.message : String(apiErr); + + // invalid_client: refresh_token was issued against a different + // OAuth client than what's now in client_secret.json (usually + // after credentials were rotated or rewritten). Re-auth required. + if (/invalid_client/i.test(errMsg)) { + return NextResponse.json({ + ok: false, + message: `gws ${version} keyring is out of sync with client_secret.json (invalid_client). Click "Remove" on the authorized account then "Authorize Account" to re-link.`, + }); + } + + // invalid_grant: refresh token expired or revoked. + if (/invalid_grant/i.test(errMsg)) { + return NextResponse.json({ + ok: false, + message: `gws ${version} refresh token is expired or revoked. Click "Remove" then "Authorize Account" to renew.`, + }); + } + + // 403 with insufficient scope: the granted scopes don't cover the + // service being called. Re-auth with the right scopes. + if (/insufficientPermissions|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(errMsg)) { + return NextResponse.json({ + ok: false, + message: `gws ${version} access token does not include Gmail scope. Re-authorize via "Remove" then "Authorize Account" (the OAuth flow requests all scopes explicitly).`, + }); + } + if (errMsg.includes("401") || errMsg.includes("credentials") || errMsg.includes("auth")) { return NextResponse.json({ ok: false, message: `gws ${version} has credentials but tokens are invalid. Re-authorize by clicking "Authorize Account".`, }); } + // Non-auth error (API not enabled, project mismatch, etc.) -- auth itself is fine return NextResponse.json({ ok: true, diff --git a/settings/src/app/integrations/google/page.tsx b/settings/src/app/integrations/google/page.tsx index 2dcd81d..f470514 100644 --- a/settings/src/app/integrations/google/page.tsx +++ b/settings/src/app/integrations/google/page.tsx @@ -58,6 +58,7 @@ export default function GoogleSettingsPage() { // Authorized accounts state const [accounts, setAccounts] = useState([]); const [hasValidToken, setHasValidToken] = useState(false); + const [tokenError, setTokenError] = useState(null); const [authorizing, setAuthorizing] = useState(false); const [removeTarget, setRemoveTarget] = useState(null); @@ -111,6 +112,7 @@ export default function GoogleSettingsPage() { setGwsVersion(statusData.gwsVersion ?? ""); setAccounts(statusData.accounts ?? []); setHasValidToken(statusData.hasValidToken ?? false); + setTokenError(statusData.tokenError ?? null); setServices(statusData.services ?? "all"); setInitialServices(statusData.services ?? "all"); } catch (err) { @@ -330,13 +332,21 @@ export default function GoogleSettingsPage() {
Authorized Accounts 0 || hasValidToken ? "connected" : "not_configured"} + status={ + tokenError + ? "not_configured" + : accounts.length > 0 || hasValidToken + ? "connected" + : "not_configured" + } label={ - accounts.length > 0 - ? `${accounts.length} authorized` - : hasValidToken - ? "Authorized (token valid)" - : "None authorized" + tokenError + ? `Broken token (${tokenError})` + : accounts.length > 0 + ? `${accounts.length} authorized` + : hasValidToken + ? "Authorized (token valid)" + : "None authorized" } />
@@ -444,6 +454,23 @@ export default function GoogleSettingsPage() {

)} + {tokenError && ( +
+ +
+

Authorized account has a broken token ({tokenError}).

+

+ {tokenError === "invalid_client" + ? "The refresh token in the gws keyring was issued against a different OAuth client than the one currently configured (usually after credentials were rotated or rewritten). " + : tokenError === "invalid_grant" + ? "The refresh token has expired or been revoked. " + : "Token refresh against Google failed. "} + Click Remove on the authorized account below, + then Authorize Account to re-link. +

+
+
+ )} {/* OAuth Credentials */} @@ -532,10 +559,37 @@ export default function GoogleSettingsPage() {
{account.email} - {account.default && default} + {account.default && default}
+ {!account.default && accounts.length > 1 && ( + + )} {!isConfigured && ( Configure OAuth credentials above first diff --git a/settings/src/lib/gws-accounts.ts b/settings/src/lib/gws-accounts.ts new file mode 100644 index 0000000..6ab4e59 --- /dev/null +++ b/settings/src/lib/gws-accounts.ts @@ -0,0 +1,175 @@ +/** + * Settings-side mirror of the multi-account gws helper in + * `src/auth/gws-accounts.ts` (main package). The two files implement the + * same on-disk contract — `~/.config/gws/accounts.json` + per-account + * subdirs — so the Settings UI and the daemon agree on layout. + * + * Kept duplicated because the Settings UI is a separate Next.js package + * and we don't share TypeScript modules across the boundary. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export interface AccountManifestEntry { + email: string; + addedAt: string; + isDefault: boolean; +} + +export interface AccountManifest { + version: 1; + accounts: AccountManifestEntry[]; +} + +export function gwsRootDir(): string { + const xdg = process.env.XDG_CONFIG_HOME; + return xdg ? path.join(xdg, "gws") : path.join(os.homedir(), ".config", "gws"); +} + +export function accountDir(email: string): string { + return path.join(gwsRootDir(), email); +} + +export function manifestPath(): string { + return path.join(gwsRootDir(), "accounts.json"); +} + +export function readManifest(): AccountManifest { + const p = manifestPath(); + if (!fs.existsSync(p)) return { version: 1, accounts: [] }; + try { + const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Partial; + if (raw && raw.version === 1 && Array.isArray(raw.accounts)) { + return raw as AccountManifest; + } + } catch { + // ignore corrupt manifest + } + return { version: 1, accounts: [] }; +} + +export function writeManifest(manifest: AccountManifest): void { + const root = gwsRootDir(); + if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true }); + const p = manifestPath(); + const tmp = `${p}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), "utf8"); + fs.renameSync(tmp, p); +} + +export function listAccounts(): AccountManifestEntry[] { + return readManifest().accounts; +} + +export function addAccountToManifest(email: string, opts?: { makeDefault?: boolean }): void { + const dir = accountDir(email); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const manifest = readManifest(); + const existing = manifest.accounts.find((a) => a.email === email); + const makeDefault = opts?.makeDefault ?? manifest.accounts.length === 0; + + if (makeDefault) { + for (const a of manifest.accounts) a.isDefault = false; + } + + if (existing) { + if (makeDefault) existing.isDefault = true; + } else { + manifest.accounts.push({ + email, + addedAt: new Date().toISOString(), + isDefault: makeDefault, + }); + } + + writeManifest(manifest); +} + +export function removeAccountFromManifest(email: string): void { + const manifest = readManifest(); + const wasDefault = manifest.accounts.find((a) => a.email === email)?.isDefault ?? false; + manifest.accounts = manifest.accounts.filter((a) => a.email !== email); + if (wasDefault && manifest.accounts.length > 0) { + manifest.accounts[0].isDefault = true; + } + writeManifest(manifest); + + const dir = accountDir(email); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); +} + +export function setDefaultAccount(email: string): void { + const manifest = readManifest(); + let found = false; + for (const a of manifest.accounts) { + if (a.email === email) { + a.isDefault = true; + found = true; + } else { + a.isDefault = false; + } + } + if (!found) throw new Error(`Account not in manifest: ${email}`); + writeManifest(manifest); +} + +/** Path to a pending (pre-rename) auth working dir. */ +export function pendingAuthDir(token: string): string { + return path.join(gwsRootDir(), `.pending-${token}`); +} + +/** Generate a short random token for pending auth dirs. */ +export function newPendingToken(): string { + return Math.random().toString(36).slice(2, 10) + Date.now().toString(36); +} + +/** Write client_secret.json into an arbitrary directory (per-account or pending). */ +export function writeClientSecretToDir( + dir: string, + params: { clientId: string; clientSecret: string; projectId: string }, +): string { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const dest = path.join(dir, "client_secret.json"); + const data = { + installed: { + client_id: params.clientId, + client_secret: params.clientSecret, + project_id: params.projectId || "google-workspace-cli", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + redirect_uris: ["http://localhost"], + }, + }; + fs.writeFileSync(dest, JSON.stringify(data, null, 2), { mode: 0o600 }); + return dest; +} + +/** + * Promote a pending auth dir to a final per-account dir. Moves the + * directory contents and registers the email in the manifest. If a dir + * already exists for this email (re-auth), the pending dir replaces it. + */ +export function promotePendingDir(token: string, email: string): string { + const src = pendingAuthDir(token); + const dest = accountDir(email); + + if (!fs.existsSync(src)) { + throw new Error(`Pending dir missing: ${src}`); + } + + if (fs.existsSync(dest)) { + fs.rmSync(dest, { recursive: true, force: true }); + } + fs.renameSync(src, dest); + + addAccountToManifest(email); + return dest; +} + +export function cleanupPendingDir(token: string): void { + const p = pendingAuthDir(token); + if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); +} diff --git a/settings/src/lib/sync-google-accounts.ts b/settings/src/lib/sync-google-accounts.ts index 63b7d6f..cb24c1e 100644 --- a/settings/src/lib/sync-google-accounts.ts +++ b/settings/src/lib/sync-google-accounts.ts @@ -1,26 +1,68 @@ /** - * Sync Google Workspace account from `gws auth status` into the DB. + * Sync Google Workspace accounts from the on-disk manifest + * (`~/.config/gws/accounts.json`) into the integrations table. * - * The `gws` CLI owns OAuth tokens (~/.config/gws/). We persist account - * metadata (email, default status) in the integrations table so the agent - * can reference which Google accounts are available. + * The manifest, written by `src/lib/gws-accounts.ts` during the OAuth + * flow, is the source of truth. The DB just caches it so other surfaces + * (Settings UI status, agent system prompt) can read accounts without + * touching the filesystem. * - * In gws v0.22.5+, there is at most one authenticated account. + * Falls back to single-account `gws auth status` for legacy installs + * whose manifest hasn't been populated yet — that path is removed once + * the first multi-account auth migrates the install. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { getDb } from "./db"; +import { listAccounts } from "./gws-accounts"; const execFileAsync = promisify(execFile); export async function syncGoogleAccountsToDb(): Promise< Array<{ email: string; is_default: boolean }> > { - // Check if gws has a valid auth session + const manifest = listAccounts(); + + // Primary path: manifest-driven sync. + if (manifest.length > 0) { + const sql = getDb(); + + const manifestEmails = new Set(manifest.map((a) => a.email)); + + for (const acct of manifest) { + const name = `google-ws:${acct.email}`; + const metadata = JSON.stringify({ is_default: acct.isDefault }); + await sql` + INSERT INTO integrations (name, enabled, config, secrets, metadata) + VALUES (${name}, true, '{}', '{}', ${metadata}::jsonb) + ON CONFLICT (name) DO UPDATE SET + metadata = ${metadata}::jsonb, + updated_at = now() + `; + } + + // Drop stale DB rows for accounts no longer in the manifest. + const existing = await sql<{ name: string }[]>` + SELECT name FROM integrations WHERE name LIKE 'google-ws:%' + `; + for (const row of existing) { + const email = row.name.replace(/^google-ws:/, ""); + if (!manifestEmails.has(email)) { + await sql`DELETE FROM integrations WHERE name = ${row.name}`; + } + } + + return manifest.map((a) => ({ email: a.email, is_default: a.isDefault })); + } + + // Legacy fallback: single-account gws auth status. Used by pre-migration + // installs until the first multi-account auth populates the manifest. let authenticated = false; try { - const { stdout } = await execFileAsync("npx", ["gws", "auth", "status"], { timeout: 10000 }); + const { stdout } = await execFileAsync("npx", ["@googleworkspace/cli", "auth", "status"], { + timeout: 10_000, + }); const status = JSON.parse(stdout); authenticated = status.auth_method !== "none" || status.token_cache_exists || status.storage !== "none"; @@ -30,13 +72,12 @@ export async function syncGoogleAccountsToDb(): Promise< if (!authenticated) return []; - // Try to resolve the email of the authenticated account let email: string | null = null; try { const { stdout: exportOut } = await execFileAsync( "npx", - ["gws", "auth", "export", "--unmasked"], - { timeout: 10000 }, + ["@googleworkspace/cli", "auth", "export", "--unmasked"], + { timeout: 10_000 }, ); const creds = JSON.parse(exportOut); if (creds.refresh_token && creds.client_id && creds.client_secret) { @@ -71,7 +112,6 @@ export async function syncGoogleAccountsToDb(): Promise< if (!email) return []; - // Upsert the account in DB const sql = getDb(); const name = `google-ws:${email}`; const metadata = JSON.stringify({ is_default: true }); diff --git a/src/auth/gws-accounts.ts b/src/auth/gws-accounts.ts new file mode 100644 index 0000000..d4b26b8 --- /dev/null +++ b/src/auth/gws-accounts.ts @@ -0,0 +1,265 @@ +/** + * Multi-account helper for the Google Workspace CLI (`gws`). + * + * Implements the convention from + * https://github.com/indentcorp/gws-multi-account (see + * https://github.com/googleworkspace/cli/issues/78 for upstream context): + * + * ~/.config/gws/ + * ├── accounts.json — manifest: which emails are authorized + * ├── personal@gmail.com/ + * │ ├── client_secret.json + * │ ├── credentials.enc + * │ └── token_cache.json + * └── work@company.com/ + * └── ... (same shape) + * + * Every gws invocation must prepend + * `GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.config/gws/` so it picks the + * right account; the gws CLI itself is single-account so the env var is + * the entire multi-account mechanism. + * + * The DB (`google_accounts` via `src/db/google-accounts.ts`) is a cache of + * this manifest, used for sync to other surfaces (Settings UI, system + * prompt). The on-disk manifest is the source of truth. + */ + +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { promisify } from "node:util"; +import { createLogger } from "../lib/logger.ts"; + +const execFileAsync = promisify(execFile); +const log = createLogger("gws-accounts"); + +/** Manifest entry on disk. */ +export interface AccountManifestEntry { + email: string; + /** ISO timestamp of when this account was first authorized. */ + addedAt: string; + /** Whether this is the default account when no email is specified. */ + isDefault: boolean; +} + +export interface AccountManifest { + version: 1; + accounts: AccountManifestEntry[]; +} + +/** Root directory holding per-account subdirs. Honors XDG_CONFIG_HOME. */ +export function gwsRootDir(): string { + const xdg = process.env.XDG_CONFIG_HOME; + return xdg ? path.join(xdg, "gws") : path.join(os.homedir(), ".config", "gws"); +} + +/** Per-account config directory. */ +export function accountDir(email: string): string { + return path.join(gwsRootDir(), email); +} + +/** Path to the multi-account manifest. */ +export function manifestPath(): string { + return path.join(gwsRootDir(), "accounts.json"); +} + +/** Read the on-disk manifest. Returns an empty manifest if absent or corrupt. */ +export function readManifest(): AccountManifest { + const p = manifestPath(); + if (!fs.existsSync(p)) { + return { version: 1, accounts: [] }; + } + try { + const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Partial; + if (raw && raw.version === 1 && Array.isArray(raw.accounts)) { + return raw as AccountManifest; + } + } catch (err) { + log.warn({ err: err instanceof Error ? err.message : err }, "Manifest corrupt; ignoring"); + } + return { version: 1, accounts: [] }; +} + +/** Atomically write the manifest (temp file + rename). */ +export function writeManifest(manifest: AccountManifest): void { + const root = gwsRootDir(); + if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true }); + const p = manifestPath(); + const tmp = `${p}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), "utf8"); + fs.renameSync(tmp, p); +} + +export function listAccounts(): AccountManifestEntry[] { + return readManifest().accounts; +} + +export function getDefaultAccount(): AccountManifestEntry | null { + const accounts = listAccounts(); + return accounts.find((a) => a.isDefault) ?? accounts[0] ?? null; +} + +export function getAccount(email: string): AccountManifestEntry | null { + return listAccounts().find((a) => a.email === email) ?? null; +} + +/** + * Register an account in the manifest. Creates the per-account dir if + * absent. If this is the first account it becomes default automatically. + */ +export function addAccount(email: string, opts?: { makeDefault?: boolean }): void { + const dir = accountDir(email); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const manifest = readManifest(); + const existing = manifest.accounts.find((a) => a.email === email); + const makeDefault = opts?.makeDefault ?? manifest.accounts.length === 0; + + if (makeDefault) { + for (const a of manifest.accounts) a.isDefault = false; + } + + if (existing) { + if (makeDefault) existing.isDefault = true; + } else { + manifest.accounts.push({ + email, + addedAt: new Date().toISOString(), + isDefault: makeDefault, + }); + } + + writeManifest(manifest); +} + +/** + * Remove an account from the manifest and delete its config directory. + * If the removed account was default, promote the first remaining one. + */ +export function removeAccount(email: string): void { + const manifest = readManifest(); + const wasDefault = manifest.accounts.find((a) => a.email === email)?.isDefault ?? false; + manifest.accounts = manifest.accounts.filter((a) => a.email !== email); + if (wasDefault && manifest.accounts.length > 0) { + manifest.accounts[0].isDefault = true; + } + writeManifest(manifest); + + const dir = accountDir(email); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** Mark the given account as default; demote all others. */ +export function setDefaultAccount(email: string): void { + const manifest = readManifest(); + let found = false; + for (const a of manifest.accounts) { + if (a.email === email) { + a.isDefault = true; + found = true; + } else { + a.isDefault = false; + } + } + if (!found) throw new Error(`Account not in manifest: ${email}`); + writeManifest(manifest); +} + +/** + * Copy a fresh client_secret.json into an account's config dir. + * Required before running `gws auth login` for that account. + */ +export function writeClientSecret( + email: string, + payload: { + clientId: string; + clientSecret: string; + projectId: string; + redirectUris?: string[]; + }, +): void { + const dir = accountDir(email); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + // Standard Google "installed" OAuth client schema. + const content = { + installed: { + client_id: payload.clientId, + client_secret: payload.clientSecret, + project_id: payload.projectId, + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + redirect_uris: payload.redirectUris ?? ["http://localhost"], + }, + }; + + fs.writeFileSync(path.join(dir, "client_secret.json"), JSON.stringify(content, null, 2), "utf8"); +} + +/** Environment additions to make `gws` operate on the given account. */ +export function envForAccount(email: string): Record { + return { GOOGLE_WORKSPACE_CLI_CONFIG_DIR: accountDir(email) }; +} + +export interface RunGwsOptions { + timeoutMs?: number; + /** Extra env on top of the account env. */ + env?: Record; +} + +export interface RunGwsResult { + stdout: string; + stderr: string; +} + +/** + * Run a `gws` command scoped to the given account. Throws on non-zero exit + * with stderr captured in the error message. + */ +export async function runGws( + email: string, + args: string[], + options: RunGwsOptions = {}, +): Promise { + const acct = getAccount(email); + if (!acct) throw new Error(`Unknown Google account: ${email}`); + + const env = { + ...process.env, + ...envForAccount(email), + ...(options.env ?? {}), + }; + + try { + const { stdout, stderr } = await execFileAsync("npx", ["@googleworkspace/cli", ...args], { + timeout: options.timeoutMs ?? 30_000, + env: env as NodeJS.ProcessEnv, + maxBuffer: 50 * 1024 * 1024, + }); + return { stdout, stderr }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`gws call failed (${email}): ${message}`); + } +} + +/** + * Run a `gws` command and parse the JSON output. The CLI sometimes prefixes + * with a "Using keyring backend: ..." line, so we slice from the first `{` or `[`. + */ +export async function runGwsJson( + email: string, + args: string[], + options: RunGwsOptions = {}, +): Promise { + const { stdout } = await runGws(email, args, options); + const jsonStart = stdout.search(/[\[{]/); + if (jsonStart < 0) { + throw new Error(`gws ${args.join(" ")} returned no JSON output`); + } + return JSON.parse(stdout.slice(jsonStart)) as T; +} diff --git a/src/cli/chat.ts b/src/cli/chat.ts index 21bd434..d1a0b17 100644 --- a/src/cli/chat.ts +++ b/src/cli/chat.ts @@ -10,7 +10,7 @@ import { isDiscordConfigured, createDiscordMcpServer } from "../sdk/discord-mcp. import { isTelegramConfigured, createTelegramMcpServer } from "../sdk/telegram-mcp.ts"; import { isGoogleWorkspaceConfigured, - createGoogleWorkspaceMcpConfigs, + createGoogleWorkspaceMcpServer, } from "../sdk/google-workspace-mcp.ts"; import { startRepl } from "../ui/repl.tsx"; import { loadMcpConfig } from "./mcp-config.ts"; @@ -87,7 +87,7 @@ export function registerChatCommand(program: Command): void { mcpServers["nomos-telegram"] = createTelegramMcpServer(); } if (isGoogleWorkspaceConfigured()) { - Object.assign(mcpServers, createGoogleWorkspaceMcpConfigs()); + mcpServers["nomos-google-workspace"] = createGoogleWorkspaceMcpServer(); } // Determine session key: diff --git a/src/cli/ingest.ts b/src/cli/ingest.ts index fa09ac8..b57aa7b 100644 --- a/src/cli/ingest.ts +++ b/src/cli/ingest.ts @@ -81,16 +81,21 @@ export function registerIngestCommand(program: Command): void { // nomos ingest gmail ingest .command("gmail") - .description("Ingest sent emails from Gmail") + .description("Ingest sent emails from Gmail (iterates all accounts unless --account is set)") .option("--since ", "Only ingest messages after this date") .option("--run-type ", "Run type: full or delta", "full") .option("--contact ", "Filter to specific contact") + .option( + "--account ", + "Restrict to one authorized account. Omit to iterate every account in ~/.config/gws/accounts.json.", + ) .option("--dry-run", "Count messages without storing") .action(async (opts) => { getDb(); try { const source = new GmailIngestSource(); - console.log(chalk.blue("Ingesting from Gmail (sent folder)...")); + const label = opts.account ? ` (${opts.account})` : " (all accounts)"; + console.log(chalk.blue(`Ingesting from Gmail (sent folder)${label}...`)); await runSource(source, opts); } finally { await closeDb(); @@ -207,13 +212,20 @@ export function registerIngestCommand(program: Command): void { async function runSource( source: IngestSource, - opts: { since?: string; contact?: string; dryRun?: boolean; runType?: string }, + opts: { + since?: string; + contact?: string; + dryRun?: boolean; + runType?: string; + account?: string; + }, ): Promise { const options: IngestOptions = { since: opts.since ? new Date(opts.since) : undefined, contact: opts.contact, dryRun: opts.dryRun, runType: (opts.runType as "full" | "delta") ?? "full", + account: opts.account, }; if (opts.dryRun) { diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index fa28940..fd96e9d 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -227,8 +227,21 @@ export class AgentRuntime { this.mcpServers["nomos-telegram"] = createTelegramMcpServer(); } if (await isGoogleWorkspaceConfiguredAsync()) { - // gws CLI is used via Bash (not MCP) -- no MCP server to register. - // Sync authorized accounts from gws CLI to DB and load for system prompt + // Register the in-process Google Workspace MCP (gmail_*, calendar_*, + // etc.). Tools shell out to the `gws` CLI under the hood with the + // right per-account `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`, so the agent + // gets typed tools and native multi-account in one server. + try { + const { createGoogleWorkspaceMcpServer } = await import("../sdk/google-workspace-mcp.ts"); + this.mcpServers["nomos-google-workspace"] = createGoogleWorkspaceMcpServer(); + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : err }, + "Failed to register google-workspace MCP", + ); + } + + // Load authorized accounts for the system prompt. try { const { syncGoogleAccountsFromGws } = await import("../db/google-accounts.ts"); const accounts = await syncGoogleAccountsFromGws(); diff --git a/src/db/google-accounts.ts b/src/db/google-accounts.ts index db105bb..bde6acf 100644 --- a/src/db/google-accounts.ts +++ b/src/db/google-accounts.ts @@ -63,31 +63,60 @@ export async function removeGoogleAccount(email: string): Promise { } /** - * Sync accounts from gws auth status into the DB. - * In v0.22.5+, gws supports one account at a time. - * We add it to the DB but don't remove old accounts (user may re-auth them later). + * Sync accounts from the on-disk gws multi-account manifest + * (`~/.config/gws/accounts.json`, managed by `src/auth/gws-accounts.ts`) + * into the DB. The manifest is the source of truth; the DB caches it so + * other surfaces (Settings UI, system prompt) can read accounts without + * touching the filesystem. + * + * Falls back to the legacy single-account `gws auth status` path if the + * manifest is empty (so existing single-account installs keep working + * until the next auth flow migrates them to the manifest). */ export async function syncGoogleAccountsFromGws(): Promise { try { - const { getGwsAuthStatus } = await import("../sdk/google-workspace-mcp.ts"); - const status = await getGwsAuthStatus(); + const { listAccounts } = await import("../auth/gws-accounts.ts"); + const manifest = listAccounts(); + + if (manifest.length > 0) { + const manifestEmails = new Set(manifest.map((a) => a.email)); + + // Upsert every manifest account. + for (const acct of manifest) { + await upsertIntegration(integrationName(acct.email), { + metadata: { is_default: acct.isDefault }, + }); + } - if (status.authenticated && status.email) { - // Mark the current gws account as default, unmark others + // Drop DB rows for accounts that no longer exist in the manifest. const existing = await listGoogleAccounts(); - for (const account of existing) { - if (account.is_default && account.email !== status.email) { - await upsertIntegration(integrationName(account.email), { - metadata: { is_default: false }, - }); + for (const acct of existing) { + if (!manifestEmails.has(acct.email)) { + await removeIntegration(integrationName(acct.email)); + } + } + } else { + // No manifest yet — fall back to the legacy single-account path so + // pre-migration installs continue to work until the user runs the + // multi-account auth flow. + const { getGwsAuthStatus } = await import("../sdk/google-workspace-mcp.ts"); + const status = await getGwsAuthStatus(); + if (status.authenticated && status.email) { + const existing = await listGoogleAccounts(); + for (const account of existing) { + if (account.is_default && account.email !== status.email) { + await upsertIntegration(integrationName(account.email), { + metadata: { is_default: false }, + }); + } } + await upsertIntegration(integrationName(status.email), { + metadata: { is_default: true }, + }); } - await upsertIntegration(integrationName(status.email), { - metadata: { is_default: true }, - }); } } catch { - // gws not available -- return current DB state + // Manifest/gws not available -- return current DB state } return listGoogleAccounts(); diff --git a/src/ingest/sources/gmail.ts b/src/ingest/sources/gmail.ts index 177c899..44dec53 100644 --- a/src/ingest/sources/gmail.ts +++ b/src/ingest/sources/gmail.ts @@ -14,6 +14,11 @@ import { GoogleAuth, OAuth2Client } from "google-auth-library"; import { execFile } from "node:child_process"; import type { IngestSource, IngestMessage, IngestOptions } from "../types.ts"; import { createLogger } from "../../lib/logger.ts"; +import { + envForAccount, + getDefaultAccount, + listAccounts as listGwsManifestAccounts, +} from "../../auth/gws-accounts.ts"; const log = createLogger("ingest-gmail"); @@ -45,24 +50,23 @@ export class GmailIngestSource implements IngestSource { private latestHistoryId: string | null = null; /** - * Get an access token for Gmail API. + * Get an access token for Gmail API, scoped to a specific account. * * Priority: - * 1. gws CLI tokens (the common Google Workspace OAuth path). - * 2. Application Default Credentials — ONLY when gws is not set up - * at all. ADC tokens from `gcloud auth application-default login` - * do not include Gmail scope, so falling back to ADC after a gws - * failure just hides the real problem (you get a 403 + * 1. gws CLI tokens for the given account (the common path). If no + * account is named and the multi-account manifest is empty, falls + * back to whatever gws has in its default location. + * 2. Application Default Credentials — only when gws is not set up at + * all. ADC tokens from `gcloud auth application-default login` do + * not include Gmail scope, so falling back to ADC after a gws + * failure just hides the real problem (403 * ACCESS_TOKEN_SCOPE_INSUFFICIENT on the first Gmail call). */ - private async getAccessToken(): Promise { - const creds = await exportGwsCredentials(); + private async getAccessToken(account?: string): Promise { + const creds = await exportGwsCredentials(account); if (creds) { - // gws is configured — its tokens are the only path that has Gmail - // scope. If refresh fails here, do NOT fall back to ADC; surface - // the real error so the user can re-authorize. - log.info("Using gws CLI credentials for Gmail access"); + log.info({ account: account ?? "(default)" }, "Using gws CLI credentials for Gmail access"); try { const oauth2 = new OAuth2Client(creds.client_id, creds.client_secret); oauth2.setCredentials({ refresh_token: creds.refresh_token }); @@ -79,9 +83,8 @@ export class GmailIngestSource implements IngestSource { ? "the refresh token is expired or has been revoked" : "the gws OAuth refresh failed"; throw new Error( - `Gmail auth failed: ${message}. Cause: ${hint}. ` + - 'Fix: in Settings UI → Integrations → Google, click "Remove" on the authorized account then "Authorize Account". ' + - "Or from a shell: `npx @googleworkspace/cli auth logout` then re-run the OAuth flow.", + `Gmail auth failed for ${account ?? "default account"}: ${message}. Cause: ${hint}. ` + + 'Fix: in Settings UI → Integrations → Google, click "Remove" on this account then "Add another account" to re-authorize.', ); } } @@ -109,7 +112,30 @@ export class GmailIngestSource implements IngestSource { options: IngestOptions, cursor?: string, ): AsyncGenerator { - const accessToken = await this.getAccessToken(); + // Resolve the set of accounts to ingest. If the caller named one, + // honor it. Otherwise read the manifest and iterate every account. + // The default-account-only fallback preserves single-account behavior + // when no manifest exists yet. + const accountsToIngest = this.resolveAccounts(options.account); + + for (const account of accountsToIngest) { + yield* this.ingestAccount(account, options, cursor); + } + } + + private resolveAccounts(named?: string): Array { + if (named) return [named]; + const manifest = listGwsManifestAccounts(); + if (manifest.length === 0) return [undefined]; // legacy single-account path + return manifest.map((a) => a.email); + } + + private async *ingestAccount( + account: string | undefined, + options: IngestOptions, + cursor?: string, + ): AsyncGenerator { + const accessToken = await this.getAccessToken(account); // Build query: sent folder only let query = "in:sent"; @@ -262,15 +288,26 @@ interface GwsCredentials { } /** - * Export credentials from the gws CLI (`gws auth export`). + * Export credentials from the gws CLI (`gws auth export --unmasked`) + * scoped to a specific account via `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`. + * If no account is named, uses the manifest's default account (or + * whatever gws has in its global location if the manifest is empty). + * * Returns null if gws is not available or not authenticated. + * + * `--unmasked` is required: without it, gws returns truncated values + * like `GOCS...I9oF` and `1//0...5y0w` for display purposes, which then + * fail token refresh with `invalid_client`. */ -function exportGwsCredentials(): Promise { +function exportGwsCredentials(account?: string): Promise { + const target = account ?? getDefaultAccount()?.email; + const accountEnv = target ? envForAccount(target) : {}; + return new Promise((resolve) => { execFile( "npx", - ["@googleworkspace/cli", "auth", "export"], - { timeout: 15_000 }, + ["@googleworkspace/cli", "auth", "export", "--unmasked"], + { timeout: 15_000, env: { ...process.env, ...accountEnv } as NodeJS.ProcessEnv }, (err, stdout, stderr) => { if (err) { log.warn({ err: err.message, stderr: stderr?.trim() }, "gws auth export failed"); diff --git a/src/ingest/types.ts b/src/ingest/types.ts index ee35007..ad33388 100644 --- a/src/ingest/types.ts +++ b/src/ingest/types.ts @@ -35,6 +35,12 @@ export interface IngestOptions { embeddingBatchSize?: number; /** Run type: full (initial) or delta (incremental) */ runType?: "full" | "delta"; + /** + * For sources that support multiple accounts (currently Gmail), restrict + * to a single account by email. If omitted, the source iterates every + * authorized account. + */ + account?: string; } export interface IngestProgress { diff --git a/src/memory/embeddings.ts b/src/memory/embeddings.ts index 32e54bd..cb16de1 100644 --- a/src/memory/embeddings.ts +++ b/src/memory/embeddings.ts @@ -36,44 +36,22 @@ async function generateEmbeddingsGemini(texts: string[]): Promise { if (batch.length === 1) { // Single text -- use embedContent const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:embedContent?key=${apiKey}`; - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: `models/${model}`, - content: { parts: [{ text: batch[0] }] }, - outputDimensionality: EMBEDDING_DIM, - }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(`Gemini embedding error ${response.status}: ${body}`); - } - - const data = (await response.json()) as GeminiEmbeddingResponse; + const data = (await fetchGeminiWithRetry(url, { + model: `models/${model}`, + content: { parts: [{ text: batch[0] }] }, + outputDimensionality: EMBEDDING_DIM, + })) as GeminiEmbeddingResponse; results.push(data.embedding.values); } else { // Batch -- use batchEmbedContents const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:batchEmbedContents?key=${apiKey}`; - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - requests: batch.map((text) => ({ - model: `models/${model}`, - content: { parts: [{ text }] }, - outputDimensionality: EMBEDDING_DIM, - })), - }), - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(`Gemini batch embedding error ${response.status}: ${body}`); - } - - const data = (await response.json()) as GeminiBatchResponse; + const data = (await fetchGeminiWithRetry(url, { + requests: batch.map((text) => ({ + model: `models/${model}`, + content: { parts: [{ text }] }, + outputDimensionality: EMBEDDING_DIM, + })), + })) as GeminiBatchResponse; for (const emb of data.embeddings) { results.push(emb.values); } @@ -83,6 +61,60 @@ async function generateEmbeddingsGemini(texts: string[]): Promise { return results; } +/** + * POST to a Gemini embedding endpoint with 429 retry. Free-tier quota + * is 100 embed requests/min/project — easy to hit during bulk ingest. + * Google returns `retryDelay` in the error response body; we honor it, + * cap at 60s, and retry up to 5 times before giving up. + */ +async function fetchGeminiWithRetry(url: string, body: unknown, maxAttempts = 5): Promise { + let attempt = 0; + while (true) { + attempt++; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (response.ok) { + return response.json(); + } + + const bodyText = await response.text(); + + if (response.status === 429 && attempt < maxAttempts) { + const delayMs = parseGeminiRetryDelay(bodyText) ?? Math.min(2 ** attempt * 1000, 60_000); + console.warn( + `[embeddings] Gemini 429 (attempt ${attempt}/${maxAttempts}); sleeping ${Math.round(delayMs / 1000)}s`, + ); + await new Promise((r) => setTimeout(r, delayMs + Math.random() * 500)); + continue; + } + + throw new Error(`Gemini embedding error ${response.status}: ${bodyText}`); + } +} + +/** Parse the `retryDelay` string (e.g. "32s") from Gemini's 429 body. */ +function parseGeminiRetryDelay(body: string): number | null { + try { + const parsed = JSON.parse(body); + const details = parsed?.error?.details; + if (!Array.isArray(details)) return null; + for (const d of details) { + const delay = d?.retryDelay; + if (typeof delay === "string") { + const m = delay.match(/^(\d+(?:\.\d+)?)s$/); + if (m) return Math.ceil(parseFloat(m[1]) * 1000); + } + } + } catch { + // not JSON or unexpected shape + } + return null; +} + // ── Vertex AI backend (legacy) ── interface VertexEmbeddingResponse { diff --git a/src/sdk/google-workspace-mcp.ts b/src/sdk/google-workspace-mcp.ts index a33b7a3..450c416 100644 --- a/src/sdk/google-workspace-mcp.ts +++ b/src/sdk/google-workspace-mcp.ts @@ -1,116 +1,70 @@ /** - * Google Workspace MCP server configuration. + * Google Workspace MCP server (in-process). * - * Uses @googleworkspace/cli (`gws`) for Google Workspace access. - * The `gws mcp` command starts an MCP server over stdio that - * auto-generates tools from Google's Discovery API. + * Exposes Gmail + Calendar tools to the agent. Each tool shells out to + * the `gws` CLI scoped to a specific account via the per-account + * `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` env var (see `src/auth/gws-accounts.ts`). * - * Auth is managed by `gws auth login` -- no env vars needed for - * the child process. Client credentials live in ~/.config/gws/. - * - * @see https://github.com/googleworkspace/cli + * History: an earlier version of this file tried to spawn `gws mcp` as + * an external MCP server. That subcommand was removed from + * `@googleworkspace/cli` (v0.22.5 only exposes `gws ` calls), so + * the external-server path was broken. Now we wrap the same CLI calls in + * our own in-process MCP and gain native multi-account support for free. */ +import { + createSdkMcpServer, + tool, + type McpSdkServerConfigWithInstance, +} from "@anthropic-ai/claude-agent-sdk"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod/v4"; +import { + envForAccount, + getAccount, + getDefaultAccount, + listAccounts as listAccountsFromManifest, + runGwsJson, +} from "../auth/gws-accounts.ts"; +import { createLogger } from "../lib/logger.ts"; const execFileAsync = promisify(execFile); +const log = createLogger("google-workspace-mcp"); + +// ── Capability checks (used by agent-runtime + settings UI) ── /** * Check if Google Workspace is configured (sync). * - * Returns true if `gws` has valid auth OR - * if GOOGLE_OAUTH_CLIENT_ID is set (backwards compat for settings UI setup). + * Returns true if at least one account is in the manifest OR if + * GOOGLE_OAUTH_CLIENT_ID is set (kept for the legacy single-account + * setup wizard until the multi-account flow replaces it everywhere). */ export function isGoogleWorkspaceConfigured(): boolean { - // Quick env-based check first (sync) - if (process.env.GOOGLE_OAUTH_CLIENT_ID) { - return true; - } - - // Check if gws has accounts (async check done at startup via initialize) - // For sync compatibility, also check if client_secret.json exists - try { - const fs = require("node:fs"); - const path = require("node:path"); - const os = require("node:os"); - const configPath = path.join(os.homedir(), ".config", "gws", "client_secret.json"); - return fs.existsSync(configPath); - } catch { - return false; - } + if (listAccountsFromManifest().length > 0) return true; + if (process.env.GOOGLE_OAUTH_CLIENT_ID) return true; + return false; } -/** - * Check if Google Workspace is configured (async, DB-backed). - * Checks DB for google-ws:* account entries first, then falls back to sync check. - */ +/** Async variant — same logic, kept for call-site compat. */ export async function isGoogleWorkspaceConfiguredAsync(): Promise { + if (listAccountsFromManifest().length > 0) return true; try { const { listGoogleAccounts } = await import("../db/google-accounts.ts"); const accounts = await listGoogleAccounts(); if (accounts.length > 0) return true; } catch { - // DB not available -- fall through + // DB not available } return isGoogleWorkspaceConfigured(); } -/** - * Create the MCP server config for Google Workspace via `gws mcp`. - * - * Returns a single MCP config. - * Services are controlled by the GWS_SERVICES env var (default: "all"). - */ -const DEFAULT_GWS_SERVICES = "gmail,drive,calendar,sheets,docs,slides"; - -export function createGoogleWorkspaceMcpConfigs(): Record { - const services = process.env.GWS_SERVICES ?? DEFAULT_GWS_SERVICES; - - return { - "google-workspace": { - type: "stdio", - command: "npx", - args: ["@googleworkspace/cli", "mcp", "-s", services, "--tool-mode", "compact"], - } as McpServerConfig, - }; -} - -/** - * Create GWS MCP configs with DB-backed service config. - * Reads GWS_SERVICES from DB integration config, falls back to env. - */ -export async function createGoogleWorkspaceMcpConfigsAsync(): Promise< - Record -> { - let services = process.env.GWS_SERVICES ?? DEFAULT_GWS_SERVICES; - try { - const { getIntegration } = await import("../db/integrations.ts"); - const integration = await getIntegration("google"); - if (integration?.config.services && typeof integration.config.services === "string") { - services = integration.config.services; - } - } catch { - // DB not available -- use env - } - - return { - "google-workspace": { - type: "stdio", - command: "npx", - args: ["@googleworkspace/cli", "mcp", "-s", services, "--tool-mode", "compact"], - } as McpServerConfig, - }; -} - -/** - * Check if the `gws` binary is available. - */ +/** Whether the `gws` binary is on PATH. */ export async function isGwsAvailable(): Promise<{ available: boolean; version?: string }> { try { const { stdout } = await execFileAsync("npx", ["@googleworkspace/cli", "--version"], { - timeout: 10000, + timeout: 10_000, }); const version = stdout .trim() @@ -122,90 +76,46 @@ export async function isGwsAvailable(): Promise<{ available: boolean; version?: } } -/** - * Get gws auth status (v0.22.5+). - */ -export async function getGwsAuthStatus(): Promise<{ +// ── Per-account auth status (replaces the single-account legacy path) ── + +export interface GwsAuthStatus { authenticated: boolean; authMethod: string; storage: string; tokenCacheExists: boolean; email?: string; -}> { +} + +/** + * Auth status for a specific account (or the default if `email` is null). + * Internally just runs `gws auth status` scoped to that account's config dir. + */ +export async function getGwsAuthStatus(email?: string): Promise { + const target = email ?? getDefaultAccount()?.email; + if (!target) { + return { authenticated: false, authMethod: "none", storage: "none", tokenCacheExists: false }; + } + try { + const env = { ...process.env, ...envForAccount(target) }; const { stdout } = await execFileAsync("npx", ["@googleworkspace/cli", "auth", "status"], { - timeout: 10000, + timeout: 10_000, + env: env as NodeJS.ProcessEnv, }); - const status = JSON.parse(stdout); - const authenticated = - status.auth_method !== "none" || - status.token_cache_exists === true || - status.storage !== "none"; - - let email: string | undefined; - - // Try to resolve email from credentials if authenticated - if (authenticated) { - try { - const { stdout: exportOut } = await execFileAsync( - "npx", - ["@googleworkspace/cli", "auth", "export", "--unmasked"], - { timeout: 10000 }, - ); - const creds = JSON.parse(exportOut) as Record; - if (creds.refresh_token && creds.client_id && creds.client_secret) { - const tokenRes = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: creds.client_id, - client_secret: creds.client_secret, - refresh_token: creds.refresh_token, - grant_type: "refresh_token", - }), - }); - if (tokenRes.ok) { - const tokenData = (await tokenRes.json()) as Record; - // Try userinfo - try { - const userRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", { - headers: { Authorization: `Bearer ${tokenData.access_token}` }, - }); - if (userRes.ok) { - const info = (await userRes.json()) as Record; - if (info.email) email = info.email; - } - } catch { - // openid scope not available - } - // Fallback: Gmail profile - if (!email) { - try { - const gmailRes = await fetch( - "https://gmail.googleapis.com/gmail/v1/users/me/profile", - { headers: { Authorization: `Bearer ${tokenData.access_token}` } }, - ); - if (gmailRes.ok) { - const profile = (await gmailRes.json()) as Record; - if (profile.emailAddress) email = profile.emailAddress; - } - } catch { - // Gmail not available - } - } - } - } - } catch { - // Could not export or resolve email - } - } - + const status = JSON.parse(stdout) as { + auth_method?: string; + storage?: string; + token_cache_exists?: boolean; + }; return { - authenticated, + authenticated: + (status.auth_method ?? "none") !== "none" || + status.token_cache_exists === true || + (status.storage ?? "none") !== "none", authMethod: status.auth_method ?? "none", storage: status.storage ?? "none", tokenCacheExists: status.token_cache_exists ?? false, - email, + email: target, }; } catch { return { @@ -213,29 +123,31 @@ export async function getGwsAuthStatus(): Promise<{ authMethod: "none", storage: "none", tokenCacheExists: false, + email: target, }; } } /** - * List authenticated gws accounts. - * In gws v0.22.5+, there is at most one account (from auth status). - * Falls back to DB for account metadata. + * List authorized accounts. + * + * Primary source: the on-disk manifest (`~/.config/gws/accounts.json`, + * managed by `src/auth/gws-accounts.ts`). + * Falls back to the DB for legacy single-account installs that haven't + * been migrated to the manifest yet. */ export async function listGwsAccounts(): Promise<{ accounts: Array<{ email: string; default: boolean }>; count: number; }> { - const status = await getGwsAuthStatus(); - - if (status.authenticated && status.email) { + const manifest = listAccountsFromManifest(); + if (manifest.length > 0) { return { - accounts: [{ email: status.email, default: true }], - count: 1, + accounts: manifest.map((a) => ({ email: a.email, default: a.isDefault })), + count: manifest.length, }; } - // Fall back to DB for account listing try { const { listGoogleAccounts } = await import("../db/google-accounts.ts"); const dbAccounts = await listGoogleAccounts(); @@ -244,9 +156,555 @@ export async function listGwsAccounts(): Promise<{ count: dbAccounts.length, }; } catch { - if (status.authenticated) { - return { accounts: [{ email: "(authenticated)", default: true }], count: 1 }; - } return { accounts: [], count: 0 }; } } + +// ── In-process MCP server ── + +/** Resolve an `account` arg to a known email; fall back to default. */ +function resolveAccount(arg: string | undefined): string { + if (arg) { + if (!getAccount(arg)) { + throw new Error( + `Unknown Google account: ${arg}. Authorized: ${ + listAccountsFromManifest() + .map((a) => a.email) + .join(", ") || "(none — authorize one in Settings UI)" + }`, + ); + } + return arg; + } + const def = getDefaultAccount(); + if (!def) { + throw new Error( + "No Google account is authorized. Add one via Settings UI → Integrations → Google.", + ); + } + return def.email; +} + +function textResult(text: string) { + return { content: [{ type: "text" as const, text }] }; +} + +function jsonResult(data: unknown) { + return textResult(JSON.stringify(data, null, 2)); +} + +function errorResult(message: string) { + return { content: [{ type: "text" as const, text: message }], isError: true }; +} + +/** Compose the in-process MCP server with Gmail + Calendar tools. */ +export function createGoogleWorkspaceMcpServer(): McpSdkServerConfigWithInstance { + // ── Gmail ── + + const gmailSearchTool = tool( + "gmail_search", + "Search Gmail messages. Supports the standard Gmail query syntax (e.g., 'from:alice@example.com', 'in:inbox is:unread', 'after:2026/05/01 -category:promotions'). Returns a JSON list of message stubs with id, threadId, snippet, from, subject, date.", + { + query: z.string().describe("Gmail search query."), + max: z.number().int().min(1).max(50).optional().describe("Max results (default: 20)."), + account: z + .string() + .optional() + .describe("Email of the account to use. Defaults to the default account."), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const max = args.max ?? 20; + + const listResp = await runGwsJson<{ messages?: Array<{ id: string; threadId: string }> }>( + acct, + [ + "gmail", + "users", + "messages", + "list", + "--params", + JSON.stringify({ userId: "me", q: args.query, maxResults: max }), + ], + ); + + const ids = listResp.messages ?? []; + if (ids.length === 0) { + return textResult(`No messages match "${args.query}".`); + } + + const summaries = await Promise.all( + ids.slice(0, max).map(async (m) => { + const msg = await runGwsJson<{ + id: string; + threadId: string; + snippet?: string; + internalDate?: string; + payload?: { headers?: Array<{ name: string; value: string }> }; + }>(acct, [ + "gmail", + "users", + "messages", + "get", + "--params", + JSON.stringify({ + userId: "me", + id: m.id, + format: "metadata", + metadataHeaders: ["From", "Subject", "Date"], + }), + ]); + const headers = new Map( + (msg.payload?.headers ?? []).map((h) => [h.name.toLowerCase(), h.value]), + ); + return { + id: msg.id, + threadId: msg.threadId, + snippet: msg.snippet ?? "", + from: headers.get("from") ?? "", + subject: headers.get("subject") ?? "(no subject)", + date: headers.get("date") ?? "", + }; + }), + ); + + return jsonResult({ account: acct, count: summaries.length, messages: summaries }); + } catch (err) { + return errorResult(`gmail_search failed: ${err instanceof Error ? err.message : err}`); + } + }, + { annotations: { readOnlyHint: true } }, + ); + + const gmailGetMessageTool = tool( + "gmail_get_message", + "Fetch a single Gmail message in full (subject, from, to, body). Use the id from gmail_search results.", + { + id: z.string().describe("Gmail message id."), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const msg = await runGwsJson(acct, [ + "gmail", + "users", + "messages", + "get", + "--params", + JSON.stringify({ userId: "me", id: args.id, format: "full" }), + ]); + return jsonResult(msg); + } catch (err) { + return errorResult(`gmail_get_message failed: ${err instanceof Error ? err.message : err}`); + } + }, + { annotations: { readOnlyHint: true } }, + ); + + const gmailGetThreadTool = tool( + "gmail_get_thread", + "Fetch all messages in a Gmail thread by threadId.", + { + threadId: z.string().describe("Gmail thread id."), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const thread = await runGwsJson(acct, [ + "gmail", + "users", + "threads", + "get", + "--params", + JSON.stringify({ userId: "me", id: args.threadId, format: "full" }), + ]); + return jsonResult(thread); + } catch (err) { + return errorResult(`gmail_get_thread failed: ${err instanceof Error ? err.message : err}`); + } + }, + { annotations: { readOnlyHint: true } }, + ); + + const gmailCreateDraftTool = tool( + "gmail_create_draft", + "Create a Gmail draft (does NOT send). Use threadId to reply within an existing thread; in_reply_to should be the Message-ID header of the message being replied to (find it via gmail_get_message → payload.headers).", + { + to: z.string().describe("Recipient email(s), comma-separated."), + subject: z.string(), + body: z.string().describe("Plain text body."), + cc: z.string().optional(), + bcc: z.string().optional(), + threadId: z.string().optional().describe("Thread id to reply within."), + inReplyTo: z + .string() + .optional() + .describe("Message-ID header of the message being replied to."), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const raw = buildRfc822(args); + const draft = await runGwsJson<{ id: string; message?: { id: string; threadId: string } }>( + acct, + [ + "gmail", + "users", + "drafts", + "create", + "--json", + JSON.stringify({ + message: { + raw, + ...(args.threadId ? { threadId: args.threadId } : {}), + }, + }), + "--params", + JSON.stringify({ userId: "me" }), + ], + ); + return jsonResult({ account: acct, draftId: draft.id, message: draft.message }); + } catch (err) { + return errorResult( + `gmail_create_draft failed: ${err instanceof Error ? err.message : err}`, + ); + } + }, + ); + + const gmailSendDraftTool = tool( + "gmail_send_draft", + "Send a previously-created Gmail draft. Returns the sent message id.", + { + draftId: z.string(), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const result = await runGwsJson<{ id: string; threadId: string }>(acct, [ + "gmail", + "users", + "drafts", + "send", + "--json", + JSON.stringify({ id: args.draftId }), + "--params", + JSON.stringify({ userId: "me" }), + ]); + return jsonResult({ account: acct, sent: result }); + } catch (err) { + return errorResult(`gmail_send_draft failed: ${err instanceof Error ? err.message : err}`); + } + }, + ); + + const gmailListLabelsTool = tool( + "gmail_list_labels", + "List all Gmail labels (system + user-defined) for the account.", + { account: z.string().optional() }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const labels = await runGwsJson(acct, [ + "gmail", + "users", + "labels", + "list", + "--params", + JSON.stringify({ userId: "me" }), + ]); + return jsonResult(labels); + } catch (err) { + return errorResult(`gmail_list_labels failed: ${err instanceof Error ? err.message : err}`); + } + }, + { annotations: { readOnlyHint: true } }, + ); + + // ── Calendar ── + + const calendarListEventsTool = tool( + "calendar_list_events", + "List events on a Google Calendar between two times. Returns id, summary, start, end, attendees, location, description.", + { + calendarId: z.string().optional().describe("Defaults to 'primary'."), + timeMin: z.string().describe("ISO 8601 lower bound (inclusive)."), + timeMax: z.string().describe("ISO 8601 upper bound (exclusive)."), + max: z.number().int().min(1).max(100).optional(), + query: z.string().optional().describe("Free-text search within events."), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const result = await runGwsJson(acct, [ + "calendar", + "events", + "list", + "--params", + JSON.stringify({ + calendarId: args.calendarId ?? "primary", + timeMin: args.timeMin, + timeMax: args.timeMax, + maxResults: args.max ?? 50, + singleEvents: true, + orderBy: "startTime", + ...(args.query ? { q: args.query } : {}), + }), + ]); + return jsonResult(result); + } catch (err) { + return errorResult( + `calendar_list_events failed: ${err instanceof Error ? err.message : err}`, + ); + } + }, + { annotations: { readOnlyHint: true } }, + ); + + const calendarGetEventTool = tool( + "calendar_get_event", + "Fetch a single calendar event in full.", + { + eventId: z.string(), + calendarId: z.string().optional(), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const event = await runGwsJson(acct, [ + "calendar", + "events", + "get", + "--params", + JSON.stringify({ calendarId: args.calendarId ?? "primary", eventId: args.eventId }), + ]); + return jsonResult(event); + } catch (err) { + return errorResult( + `calendar_get_event failed: ${err instanceof Error ? err.message : err}`, + ); + } + }, + { annotations: { readOnlyHint: true } }, + ); + + const calendarCreateEventTool = tool( + "calendar_create_event", + "Create a new calendar event. start/end can be either {dateTime, timeZone} for timed events or {date} for all-day events.", + { + summary: z.string(), + start: z.object({ + dateTime: z.string().optional(), + date: z.string().optional(), + timeZone: z.string().optional(), + }), + end: z.object({ + dateTime: z.string().optional(), + date: z.string().optional(), + timeZone: z.string().optional(), + }), + description: z.string().optional(), + location: z.string().optional(), + attendees: z.array(z.string()).optional().describe("Attendee emails."), + calendarId: z.string().optional(), + sendUpdates: z.enum(["all", "externalOnly", "none"]).optional(), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const body = { + summary: args.summary, + start: args.start, + end: args.end, + ...(args.description ? { description: args.description } : {}), + ...(args.location ? { location: args.location } : {}), + ...(args.attendees ? { attendees: args.attendees.map((email) => ({ email })) } : {}), + }; + const event = await runGwsJson(acct, [ + "calendar", + "events", + "insert", + "--json", + JSON.stringify(body), + "--params", + JSON.stringify({ + calendarId: args.calendarId ?? "primary", + ...(args.sendUpdates ? { sendUpdates: args.sendUpdates } : {}), + }), + ]); + return jsonResult(event); + } catch (err) { + return errorResult( + `calendar_create_event failed: ${err instanceof Error ? err.message : err}`, + ); + } + }, + ); + + const calendarUpdateEventTool = tool( + "calendar_update_event", + "Patch a calendar event. Only the fields you pass are updated; others are preserved.", + { + eventId: z.string(), + patch: z + .record(z.string(), z.unknown()) + .describe("Partial event body (summary, start, end, etc.)."), + calendarId: z.string().optional(), + sendUpdates: z.enum(["all", "externalOnly", "none"]).optional(), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + const event = await runGwsJson(acct, [ + "calendar", + "events", + "patch", + "--json", + JSON.stringify(args.patch), + "--params", + JSON.stringify({ + calendarId: args.calendarId ?? "primary", + eventId: args.eventId, + ...(args.sendUpdates ? { sendUpdates: args.sendUpdates } : {}), + }), + ]); + return jsonResult(event); + } catch (err) { + return errorResult( + `calendar_update_event failed: ${err instanceof Error ? err.message : err}`, + ); + } + }, + ); + + const calendarDeleteEventTool = tool( + "calendar_delete_event", + "Delete a calendar event.", + { + eventId: z.string(), + calendarId: z.string().optional(), + sendUpdates: z.enum(["all", "externalOnly", "none"]).optional(), + account: z.string().optional(), + }, + async (args) => { + try { + const acct = resolveAccount(args.account); + await runGwsJson(acct, [ + "calendar", + "events", + "delete", + "--params", + JSON.stringify({ + calendarId: args.calendarId ?? "primary", + eventId: args.eventId, + ...(args.sendUpdates ? { sendUpdates: args.sendUpdates } : {}), + }), + ]).catch((err) => { + // calendar.events.delete returns 204 No Content (no JSON body). runGwsJson will fail + // on the empty body parse; treat that as success and rethrow any other error. + if (err instanceof Error && /returned no JSON output/.test(err.message)) return; + throw err; + }); + return jsonResult({ account: acct, deleted: args.eventId }); + } catch (err) { + return errorResult( + `calendar_delete_event failed: ${err instanceof Error ? err.message : err}`, + ); + } + }, + ); + + // ── Account introspection ── + + const listAccountsTool = tool( + "google_list_accounts", + "List all Google accounts authorized in this Nomos install. Use the returned emails as the `account` argument on other tools.", + {}, + async () => { + const manifest = listAccountsFromManifest(); + if (manifest.length === 0) { + return textResult( + "No Google accounts authorized. Add one via Settings UI → Integrations → Google.", + ); + } + return jsonResult( + manifest.map((a) => ({ email: a.email, isDefault: a.isDefault, addedAt: a.addedAt })), + ); + }, + { annotations: { readOnlyHint: true } }, + ); + + return createSdkMcpServer({ + name: "nomos-google-workspace", + version: "1.0.0", + tools: [ + gmailSearchTool, + gmailGetMessageTool, + gmailGetThreadTool, + gmailCreateDraftTool, + gmailSendDraftTool, + gmailListLabelsTool, + calendarListEventsTool, + calendarGetEventTool, + calendarCreateEventTool, + calendarUpdateEventTool, + calendarDeleteEventTool, + listAccountsTool, + ], + }); +} + +// ── helpers ── + +/** Build a base64url-encoded RFC 822 message ready for the Gmail API `raw` field. */ +function buildRfc822(args: { + to: string; + subject: string; + body: string; + cc?: string; + bcc?: string; + inReplyTo?: string; +}): string { + const lines: string[] = []; + lines.push(`To: ${args.to}`); + if (args.cc) lines.push(`Cc: ${args.cc}`); + if (args.bcc) lines.push(`Bcc: ${args.bcc}`); + // Encode subject if non-ASCII so we don't break the header. + lines.push(`Subject: ${encodeHeaderIfNeeded(args.subject)}`); + if (args.inReplyTo) { + lines.push(`In-Reply-To: ${args.inReplyTo}`); + lines.push(`References: ${args.inReplyTo}`); + } + lines.push("MIME-Version: 1.0"); + lines.push('Content-Type: text/plain; charset="UTF-8"'); + lines.push("Content-Transfer-Encoding: 7bit"); + lines.push(""); + lines.push(args.body); + + const raw = lines.join("\r\n"); + // Gmail wants base64url (URL-safe, no padding). + return Buffer.from(raw, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function encodeHeaderIfNeeded(value: string): string { + // eslint-disable-next-line no-control-regex + if (/^[\x00-\x7F]*$/.test(value)) return value; + const encoded = Buffer.from(value, "utf8").toString("base64"); + return `=?UTF-8?B?${encoded}?=`; +} + +// Surface the logger so other modules can avoid creating duplicates. +export { log as googleWorkspaceMcpLog };