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 && (
+
{
+ try {
+ const res = await fetch("/api/google/accounts", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: account.email }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ addToast(data.error ?? "Failed to set default", "error");
+ return;
+ }
+ addToast(`${account.email} is now the default`, "success");
+ loadAccounts();
+ loadData();
+ } catch {
+ addToast("Failed to set default", "error");
+ }
+ }}
+ className="px-2 py-1 rounded text-xs text-overlay0 hover:text-text border border-surface1 hover:border-surface2 transition-colors"
+ title="Make this the default account"
+ >
+ Make default
+
+ )}
{authorizing ? : }
- {accounts.length > 0 ? "Authorize Account" : "Authorize New Account"}
+ {accounts.length > 0 ? "Add another account" : "Authorize first account"}
{!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 };