mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +21 -16
  2. package/dist/accounts.d.ts +44 -5
  3. package/dist/accounts.js +267 -25
  4. package/dist/auth.d.ts +11 -4
  5. package/dist/auth.js +85 -78
  6. package/dist/client-config.d.ts +90 -0
  7. package/dist/client-config.js +282 -0
  8. package/dist/client.js +10 -4
  9. package/dist/config-file.d.ts +38 -0
  10. package/dist/config-file.js +117 -0
  11. package/dist/discover.js +57 -3
  12. package/dist/doctor.d.ts +86 -0
  13. package/dist/doctor.js +384 -0
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +52 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -1
  18. package/dist/fanout.js +8 -5
  19. package/dist/fs-atomic.d.ts +3 -0
  20. package/dist/fs-atomic.js +123 -0
  21. package/dist/http-config.d.ts +35 -0
  22. package/dist/http-config.js +113 -0
  23. package/dist/http-transport.d.ts +59 -0
  24. package/dist/http-transport.js +269 -0
  25. package/dist/identity.d.ts +24 -0
  26. package/dist/identity.js +19 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +199 -18
  29. package/dist/master-key.d.ts +53 -0
  30. package/dist/master-key.js +242 -0
  31. package/dist/mcp-token.d.ts +81 -0
  32. package/dist/mcp-token.js +176 -0
  33. package/dist/migrate-config.d.ts +7 -0
  34. package/dist/migrate-config.js +103 -0
  35. package/dist/migrate-tokens.js +4 -3
  36. package/dist/net-tuning.d.ts +8 -0
  37. package/dist/net-tuning.js +25 -0
  38. package/dist/oauth-as.d.ts +52 -0
  39. package/dist/oauth-as.js +457 -0
  40. package/dist/oauth-consent.d.ts +28 -0
  41. package/dist/oauth-consent.js +109 -0
  42. package/dist/open-url.d.ts +1 -0
  43. package/dist/open-url.js +30 -0
  44. package/dist/reauth-hint.d.ts +2 -0
  45. package/dist/reauth-hint.js +15 -0
  46. package/dist/registry-transfer.d.ts +41 -0
  47. package/dist/registry-transfer.js +219 -0
  48. package/dist/registry.d.ts +27 -1
  49. package/dist/registry.js +117 -6
  50. package/dist/scope-catalog.d.ts +19 -0
  51. package/dist/scope-catalog.js +182 -0
  52. package/dist/scope-observability.d.ts +78 -0
  53. package/dist/scope-observability.js +192 -0
  54. package/dist/services.js +1 -1
  55. package/dist/setup-prompt.d.ts +9 -0
  56. package/dist/setup-prompt.js +59 -0
  57. package/dist/ssrf-guard.d.ts +48 -0
  58. package/dist/ssrf-guard.js +242 -0
  59. package/dist/token-store.js +23 -125
  60. package/dist/tools/_errors.d.ts +8 -2
  61. package/dist/tools/_errors.js +97 -6
  62. package/dist/tools/account-wizard.d.ts +33 -0
  63. package/dist/tools/account-wizard.js +284 -0
  64. package/dist/tools/accounts-tool.d.ts +8 -5
  65. package/dist/tools/accounts-tool.js +38 -15
  66. package/dist/tools/admin.js +2 -2
  67. package/dist/tools/calendar.js +2 -2
  68. package/dist/tools/chat.js +2 -2
  69. package/dist/tools/contacts.d.ts +40 -0
  70. package/dist/tools/contacts.js +135 -2
  71. package/dist/tools/docs.d.ts +3 -0
  72. package/dist/tools/docs.js +60 -9
  73. package/dist/tools/drive.d.ts +3 -0
  74. package/dist/tools/drive.js +60 -8
  75. package/dist/tools/forms.js +4 -2
  76. package/dist/tools/generated/_shared.d.ts +1 -3
  77. package/dist/tools/generated/_shared.js +3 -3
  78. package/dist/tools/generated/admin.js +175 -132
  79. package/dist/tools/generated/appsmarket.js +6 -2
  80. package/dist/tools/generated/calendar.js +45 -27
  81. package/dist/tools/generated/chat.js +81 -47
  82. package/dist/tools/generated/classroom.js +131 -104
  83. package/dist/tools/generated/cloudidentity.js +73 -61
  84. package/dist/tools/generated/cloudsearch.js +59 -49
  85. package/dist/tools/generated/contacts.js +22 -14
  86. package/dist/tools/generated/drive.js +51 -35
  87. package/dist/tools/generated/driveactivity.js +5 -1
  88. package/dist/tools/generated/drivelabels.js +30 -25
  89. package/dist/tools/generated/forms.js +7 -3
  90. package/dist/tools/generated/gmail.js +73 -58
  91. package/dist/tools/generated/groupsmigration.js +5 -1
  92. package/dist/tools/generated/groupssettings.js +7 -3
  93. package/dist/tools/generated/keep.js +12 -7
  94. package/dist/tools/generated/licensing.js +11 -7
  95. package/dist/tools/generated/meet.js +20 -13
  96. package/dist/tools/generated/postmaster.js +8 -4
  97. package/dist/tools/generated/reseller.js +22 -17
  98. package/dist/tools/generated/script.js +26 -16
  99. package/dist/tools/generated/sheets.js +11 -7
  100. package/dist/tools/generated/tasks.js +6 -2
  101. package/dist/tools/generated/vault.js +38 -33
  102. package/dist/tools/generated/workspaceevents.js +12 -7
  103. package/dist/tools/gmail-mime.d.ts +38 -8
  104. package/dist/tools/gmail-mime.js +75 -71
  105. package/dist/tools/gmail.d.ts +31 -0
  106. package/dist/tools/gmail.js +483 -99
  107. package/dist/tools/google-api.js +7 -4
  108. package/dist/tools/meet.js +2 -2
  109. package/dist/tools/searchconsole.js +2 -2
  110. package/dist/tools/sheets.js +6 -2
  111. package/dist/tools/slides.js +4 -2
  112. package/dist/tools/tasks.js +2 -2
  113. package/dist/types.d.ts +1 -1
  114. package/dist/write-control.d.ts +24 -1
  115. package/dist/write-control.js +26 -1
  116. package/package.json +26 -7
package/dist/auth.js CHANGED
@@ -2,8 +2,10 @@ import { OAuth2Client } from 'googleapis-common';
2
2
  import http from 'node:http';
3
3
  import { URL } from 'node:url';
4
4
  import { randomBytes } from 'node:crypto';
5
- import open from 'open';
6
- import { ACCOUNTS, ACCOUNT_CONFIG } from './accounts.js';
5
+ import { openUrl } from './open-url.js';
6
+ import { ACCOUNTS, getAccountSet } from './accounts.js';
7
+ import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
8
+ import { resolveMasterKey } from './master-key.js';
7
9
  import { writeToken } from './token-store.js';
8
10
  // Personal (non-Workspace) accounts 403 on admin scopes; ADMIN_SCOPES stays per-account opt-in, never granted by default.
9
11
  export const BASE_SCOPES = [
@@ -18,67 +20,12 @@ export const BASE_SCOPES = [
18
20
  'https://www.googleapis.com/auth/tasks',
19
21
  'https://www.googleapis.com/auth/meetings.space.readonly',
20
22
  ];
21
- export const OPTIONAL_SCOPE_BUNDLES = {
22
- slides: [
23
- 'https://www.googleapis.com/auth/presentations',
24
- ],
25
- forms: [
26
- 'https://www.googleapis.com/auth/forms.body',
27
- 'https://www.googleapis.com/auth/forms.responses.readonly',
28
- ],
29
- chat: [
30
- 'https://www.googleapis.com/auth/chat.spaces',
31
- 'https://www.googleapis.com/auth/chat.messages',
32
- 'https://www.googleapis.com/auth/chat.messages.create',
33
- ],
34
- // These extend the always-on gmail service: users.settings.* writes require these
35
- // scopes (reads already work via gmail.modify); sharing is split out as riskier.
36
- gmail_settings: [
37
- 'https://www.googleapis.com/auth/gmail.settings.basic',
38
- ],
39
- gmail_settings_sharing: [
40
- 'https://www.googleapis.com/auth/gmail.settings.sharing',
41
- ],
42
- classroom: [
43
- 'https://www.googleapis.com/auth/classroom.courses',
44
- 'https://www.googleapis.com/auth/classroom.coursework.me',
45
- 'https://www.googleapis.com/auth/classroom.coursework.students',
46
- 'https://www.googleapis.com/auth/classroom.courseworkmaterials',
47
- 'https://www.googleapis.com/auth/classroom.rosters',
48
- 'https://www.googleapis.com/auth/classroom.announcements',
49
- 'https://www.googleapis.com/auth/classroom.topics',
50
- ],
51
- cloudidentity: [
52
- 'https://www.googleapis.com/auth/cloud-identity.groups',
53
- 'https://www.googleapis.com/auth/cloud-identity.devices',
54
- ],
55
- cloudsearch: ['https://www.googleapis.com/auth/cloud_search'],
56
- vault: ['https://www.googleapis.com/auth/ediscovery'],
57
- keep: ['https://www.googleapis.com/auth/keep'],
58
- driveactivity: ['https://www.googleapis.com/auth/drive.activity.readonly'],
59
- drivelabels: [
60
- 'https://www.googleapis.com/auth/drive.labels',
61
- 'https://www.googleapis.com/auth/drive.admin.labels',
62
- ],
63
- script: [
64
- 'https://www.googleapis.com/auth/script.projects',
65
- 'https://www.googleapis.com/auth/script.deployments',
66
- 'https://www.googleapis.com/auth/script.processes',
67
- 'https://www.googleapis.com/auth/script.metrics',
68
- ],
69
- postmaster: ['https://www.googleapis.com/auth/postmaster.readonly'],
70
- groupssettings: ['https://www.googleapis.com/auth/apps.groups.settings'],
71
- groupsmigration: ['https://www.googleapis.com/auth/apps.groups.migration'],
72
- licensing: ['https://www.googleapis.com/auth/apps.licensing'],
73
- reseller: ['https://www.googleapis.com/auth/apps.order'],
74
- appsmarket: ['https://www.googleapis.com/auth/appsmarketplace.license'],
75
- };
76
- export const ADMIN_SCOPES = [
77
- 'https://www.googleapis.com/auth/admin.reports.audit.readonly',
78
- 'https://www.googleapis.com/auth/admin.directory.user',
79
- 'https://www.googleapis.com/auth/admin.directory.group.readonly',
80
- 'https://www.googleapis.com/auth/admin.directory.group.member.readonly',
81
- ];
23
+ // Kept as a derived view for compat (docs generator, tests); the catalog in
24
+ // scope-catalog.ts is the source of truth. "admin" is not an optional bundle.
25
+ export const OPTIONAL_SCOPE_BUNDLES = Object.fromEntries(Object.entries(BUNDLE_CATALOG)
26
+ .filter(([name]) => name !== 'admin')
27
+ .map(([name, entry]) => [name, entry.scopes]));
28
+ export { ADMIN_SCOPES };
82
29
  /** Parse comma-separated env value into a deduplicated string array. */
83
30
  function parseCsvEnv(name) {
84
31
  return (process.env[name]?.trim() ?? '')
@@ -86,19 +33,79 @@ function parseCsvEnv(name) {
86
33
  .map(s => s.trim())
87
34
  .filter(Boolean);
88
35
  }
89
- /** Bundle keys enabled via GOOGLE_OPTIONAL_SCOPES (e.g. ["forms","chat"]). */
36
+ /**
37
+ * Legacy global override (BC7): a set GOOGLE_OPTIONAL_SCOPES acts as an
38
+ * implicit "legacy-global" profile applied to every account (env wins over
39
+ * file profiles, cc-config R2). Unknown names now fail loudly (BR3) where v5
40
+ * silently dropped them — the intended migration signal.
41
+ */
42
+ function legacyGlobalProfile() {
43
+ const names = parseCsvEnv('GOOGLE_OPTIONAL_SCOPES');
44
+ if (names.length === 0)
45
+ return null;
46
+ const bundles = resolveBundleAliases(names);
47
+ // Boot-time validation lives in resolveAccounts (runs whatever
48
+ // GOOGLE_TOOLSETS selects); this is defense-in-depth for direct callers.
49
+ for (const bundle of bundles) {
50
+ if (bundle === 'admin') {
51
+ throw new Error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
52
+ }
53
+ if (!(bundle in BUNDLE_CATALOG)) {
54
+ const hint = closestBundle(bundle);
55
+ throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
56
+ }
57
+ }
58
+ return { bundles };
59
+ }
60
+ function profileForAccount(alias) {
61
+ const legacy = legacyGlobalProfile();
62
+ if (legacy)
63
+ return legacy;
64
+ const set = getAccountSet();
65
+ const name = set.configs[alias]?.scopeProfile ?? 'base';
66
+ // hasOwn: a profile named like an Object.prototype member must never
67
+ // resolve to the inherited function.
68
+ return Object.hasOwn(set.scopeProfiles, name) ? set.scopeProfiles[name] : { bundles: [] };
69
+ }
70
+ /** Union of every account's resolved bundles: a service registers if ANY
71
+ * account can authorize it; per-account authz happens at call time (BR2). */
90
72
  export function getOptionalBundles() {
91
- return parseCsvEnv('GOOGLE_OPTIONAL_SCOPES').filter(b => b in OPTIONAL_SCOPE_BUNDLES);
73
+ const legacy = legacyGlobalProfile();
74
+ if (legacy)
75
+ return legacy.bundles.filter(b => b !== 'admin');
76
+ const union = new Set();
77
+ const set = getAccountSet();
78
+ for (const alias of set.aliases) {
79
+ for (const b of profileForAccount(alias).bundles) {
80
+ if (b !== 'admin')
81
+ union.add(b);
82
+ }
83
+ }
84
+ return [...union];
92
85
  }
93
- /** Account aliases granted ADMIN_SCOPES via GOOGLE_ADMIN_ACCOUNTS. */
86
+ /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
87
+ * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
88
+ * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
94
89
  export function getAdminAccounts() {
95
- return parseCsvEnv('GOOGLE_ADMIN_ACCOUNTS');
90
+ const { aliases, configs } = getAccountSet();
91
+ return aliases.filter((a) => {
92
+ if (configs[a].admin === true)
93
+ return true;
94
+ const p = profileForAccount(a);
95
+ return p.admin === true || p.bundles.includes('admin');
96
+ });
96
97
  }
97
- /** Scopes are fixed at consent time: changing GOOGLE_OPTIONAL_SCOPES or GOOGLE_ADMIN_ACCOUNTS requires re-running auth. */
98
+ /** Scopes are fixed at consent time: changing an account's profile (or the
99
+ * legacy env) changes its consent set and requires re-running auth. Evaluated
100
+ * per account: `work` can carry admin + gmail_settings while `personal` is
101
+ * never asked for them. */
98
102
  export function resolveScopesForAccount(alias) {
99
- const scopes = [...BASE_SCOPES];
100
- for (const bundle of getOptionalBundles()) {
101
- scopes.push(...OPTIONAL_SCOPE_BUNDLES[bundle]);
103
+ const profile = profileForAccount(alias);
104
+ const scopes = profile.includesBase === false ? [] : [...BASE_SCOPES];
105
+ for (const bundle of profile.bundles) {
106
+ if (bundle === 'admin')
107
+ continue;
108
+ scopes.push(...BUNDLE_CATALOG[bundle].scopes);
102
109
  }
103
110
  if (getAdminAccounts().includes(alias)) {
104
111
  scopes.push(...ADMIN_SCOPES);
@@ -122,12 +129,11 @@ export async function runAuthFlow(args) {
122
129
  console.error(`Unknown account "${alias}". Valid aliases: ${ACCOUNTS.join(', ')}`);
123
130
  process.exit(1);
124
131
  }
125
- const config = ACCOUNT_CONFIG[alias];
132
+ const config = getAccountSet().configs[alias];
126
133
  const scopes = resolveScopesForAccount(alias);
127
- if (!process.env.MASTER_KEY) {
128
- console.error('MASTER_KEY is not set. Generate one (openssl rand -base64 32) and add it to .env before authenticating.');
129
- process.exit(1);
130
- }
134
+ // Auto-provisions on a fresh install (env > keychain > file > generate);
135
+ // resolves eagerly so a provisioning failure surfaces before the browser opens.
136
+ resolveMasterKey();
131
137
  const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
132
138
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
133
139
  const expectedState = randomBytes(32).toString('hex');
@@ -198,9 +204,10 @@ export async function runAuthFlow(args) {
198
204
  })
199
205
  // Bind to loopback only — never expose the OAuth callback to the local network.
200
206
  .listen(4242, '127.0.0.1', () => {
201
- // Always print the URL: `open` silently no-ops on headless/SSH sessions.
207
+ // Always print the URL first: the browser launch is best-effort and
208
+ // silently does nothing on headless/SSH sessions.
202
209
  console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
203
- open(authorizeUrl, { wait: false }).then((cp) => cp.unref());
210
+ openUrl(authorizeUrl);
204
211
  });
205
212
  server.on('error', (err) => {
206
213
  if (err.code === 'EADDRINUSE') {
@@ -0,0 +1,90 @@
1
+ export type ClientId = 'claude-code' | 'claude-desktop' | 'cursor';
2
+ export type Mode = 'stdio' | 'http';
3
+ export declare const DEFAULT_SERVER_NAME = "mcp-google-multi";
4
+ export interface ClientInfo {
5
+ id: ClientId;
6
+ label: string;
7
+ /** `cli` = registered via a command (Claude Code's ~/.claude.json is
8
+ * app-managed, so we never hand-edit it); `file` = a JSON config we merge. */
9
+ managed: 'file' | 'cli';
10
+ /** For `file`: the config path. For `cli`: the file we probe for presence. */
11
+ configPath: string;
12
+ present: boolean;
13
+ }
14
+ export interface ClientDeps {
15
+ homedir?: string;
16
+ platform?: NodeJS.Platform;
17
+ env?: NodeJS.ProcessEnv;
18
+ fileExists?: (p: string) => boolean;
19
+ readFile?: (p: string) => string;
20
+ writeFile?: (p: string, data: string) => void;
21
+ mkdirp?: (dir: string) => void;
22
+ }
23
+ export declare function detectClients(deps?: ClientDeps): ClientInfo[];
24
+ export type ServerEntry = {
25
+ command: string;
26
+ args: string[];
27
+ } | {
28
+ url: string;
29
+ };
30
+ export interface EntryOptions {
31
+ name?: string;
32
+ mode: Mode;
33
+ /** Required for http mode: `${MCP_PUBLIC_URL}/mcp`. */
34
+ resourceUri?: string;
35
+ /** stdio launch override; defaults to `npx -y mcp-google-multi`. */
36
+ command?: string;
37
+ args?: string[];
38
+ }
39
+ /**
40
+ * The client `mcpServers` entry. stdio needs no secret env: the server loads
41
+ * its own .env from ~/.config/mcp-google-multi (cc-config R1), so we never
42
+ * inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
43
+ */
44
+ export declare function buildServerEntry(opts: EntryOptions): ServerEntry;
45
+ /** The `claude mcp add ...` invocation for Claude Code (cli-managed). */
46
+ export declare function claudeCodeCommand(name: string, entry: ServerEntry): string;
47
+ export interface Instruction {
48
+ client: ClientId;
49
+ kind: 'cli' | 'file';
50
+ /** The copy-pasteable command (cli) or JSON snippet (file). */
51
+ text: string;
52
+ /** Target file for `file` kind. */
53
+ path?: string;
54
+ }
55
+ export declare function renderInstruction(client: ClientInfo, name: string, entry: ServerEntry): Instruction;
56
+ export type ApplyResult = {
57
+ ok: true;
58
+ client: ClientId;
59
+ action: 'added' | 'updated';
60
+ path: string;
61
+ backup?: string;
62
+ } | {
63
+ ok: false;
64
+ client: ClientId;
65
+ reason: 'parse_error' | 'cli_managed';
66
+ message: string;
67
+ path: string;
68
+ snippet?: string;
69
+ };
70
+ /**
71
+ * Idempotently merge the entry into a file-managed client config: read latest,
72
+ * refuse to clobber an unparseable file (print the snippet instead), back up
73
+ * the prior file, update-in-place if the key exists.
74
+ */
75
+ export declare function applyFileEntry(client: ClientInfo, name: string, entry: ServerEntry, deps?: ClientDeps): ApplyResult;
76
+ /** Resolve stdio-vs-http mode from the current transport config (B11).
77
+ * http/both → a `url` entry; stdio → a `command` entry. */
78
+ export declare function resolveMode(httpConfig?: {
79
+ transport: 'stdio' | 'http' | 'both';
80
+ resourceUri: string;
81
+ }): {
82
+ mode: Mode;
83
+ resourceUri?: string;
84
+ };
85
+ /**
86
+ * CLI: `mcp-google-multi write-client-config [--client id] [--name n] [--url u]
87
+ * [--print] [--yes]`. Consent-gated (clig.dev): never writes a file-managed
88
+ * config without an explicit yes; non-TTY / `--print` prints the snippet.
89
+ */
90
+ export declare function runWriteClientConfigCli(argv: string[]): Promise<number>;
@@ -0,0 +1,282 @@
1
+ // B15: after the setup wizard finalizes an account, offer to register the
2
+ // server with the user's MCP client so they never hand-edit JSON. Pure core
3
+ // (detection + entry building + idempotent apply), deps-injectable for tests.
4
+ // Consent + non-TTY behavior live in the CLI/tool surfaces; secrets are NEVER
5
+ // inlined — the server self-loads its .env from ~/.config/mcp-google-multi, so
6
+ // a stdio entry needs no secret env at all (distribution.md B15).
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import * as readline from 'node:readline';
9
+ import path from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ export const DEFAULT_SERVER_NAME = 'mcp-google-multi';
12
+ function resolve(deps) {
13
+ return {
14
+ home: deps.homedir ?? homedir(),
15
+ platform: deps.platform ?? process.platform,
16
+ env: deps.env ?? process.env,
17
+ fileExists: deps.fileExists ?? ((p) => existsSync(p)),
18
+ readFile: deps.readFile ?? ((p) => readFileSync(p, 'utf-8')),
19
+ writeFile: deps.writeFile ?? ((p, d) => writeFileSync(p, d)),
20
+ mkdirp: deps.mkdirp ?? ((dir) => mkdirSync(dir, { recursive: true })),
21
+ };
22
+ }
23
+ // Build paths for the TARGET platform's separators, not the running host's, so
24
+ // the injected `platform` is honored deterministically (a Windows CI runner
25
+ // must still produce posix paths for a linux target, and vice versa).
26
+ function pp(r) {
27
+ return r.platform === 'win32' ? path.win32 : path.posix;
28
+ }
29
+ /** Claude Desktop's config path is OS-specific. */
30
+ function desktopConfigPath(r) {
31
+ const j = pp(r);
32
+ if (r.platform === 'darwin') {
33
+ return j.join(r.home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
34
+ }
35
+ if (r.platform === 'win32') {
36
+ const appData = r.env.APPDATA || j.join(r.home, 'AppData', 'Roaming');
37
+ return j.join(appData, 'Claude', 'claude_desktop_config.json');
38
+ }
39
+ return j.join(r.home, '.config', 'Claude', 'claude_desktop_config.json');
40
+ }
41
+ function claudeJsonPath(r) {
42
+ return pp(r).join(r.home, '.claude.json');
43
+ }
44
+ function cursorConfigPath(r) {
45
+ return pp(r).join(r.home, '.cursor', 'mcp.json');
46
+ }
47
+ export function detectClients(deps = {}) {
48
+ const r = resolve(deps);
49
+ const j = pp(r);
50
+ const desktop = desktopConfigPath(r);
51
+ const cursor = cursorConfigPath(r);
52
+ const claudeJson = claudeJsonPath(r);
53
+ return [
54
+ {
55
+ id: 'claude-code',
56
+ label: 'Claude Code',
57
+ managed: 'cli',
58
+ configPath: claudeJson,
59
+ // Best-effort: the CLI-managed file or the config dir signals a Claude
60
+ // Code install; we can't reliably probe PATH cross-platform here.
61
+ present: r.fileExists(claudeJson) || r.fileExists(j.join(r.home, '.claude')),
62
+ },
63
+ {
64
+ id: 'claude-desktop',
65
+ label: 'Claude Desktop',
66
+ managed: 'file',
67
+ configPath: desktop,
68
+ present: r.fileExists(desktop) || r.fileExists(j.dirname(desktop)),
69
+ },
70
+ {
71
+ id: 'cursor',
72
+ label: 'Cursor',
73
+ managed: 'file',
74
+ configPath: cursor,
75
+ present: r.fileExists(cursor) || r.fileExists(j.dirname(cursor)),
76
+ },
77
+ ];
78
+ }
79
+ /**
80
+ * The client `mcpServers` entry. stdio needs no secret env: the server loads
81
+ * its own .env from ~/.config/mcp-google-multi (cc-config R1), so we never
82
+ * inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
83
+ */
84
+ export function buildServerEntry(opts) {
85
+ if (opts.mode === 'http') {
86
+ if (!opts.resourceUri)
87
+ throw new Error('E_VALIDATION: http mode requires resourceUri');
88
+ return { url: opts.resourceUri };
89
+ }
90
+ return {
91
+ command: opts.command ?? 'npx',
92
+ args: opts.args ?? ['-y', 'mcp-google-multi'],
93
+ };
94
+ }
95
+ /** The `claude mcp add ...` invocation for Claude Code (cli-managed). */
96
+ export function claudeCodeCommand(name, entry) {
97
+ if ('url' in entry) {
98
+ return `claude mcp add --transport http ${name} ${entry.url}`;
99
+ }
100
+ return `claude mcp add ${name} -- ${entry.command} ${entry.args.join(' ')}`;
101
+ }
102
+ export function renderInstruction(client, name, entry) {
103
+ if (client.managed === 'cli') {
104
+ return { client: client.id, kind: 'cli', text: claudeCodeCommand(name, entry) };
105
+ }
106
+ const snippet = JSON.stringify({ mcpServers: { [name]: entry } }, null, 2);
107
+ return { client: client.id, kind: 'file', text: snippet, path: client.configPath };
108
+ }
109
+ /**
110
+ * Idempotently merge the entry into a file-managed client config: read latest,
111
+ * refuse to clobber an unparseable file (print the snippet instead), back up
112
+ * the prior file, update-in-place if the key exists.
113
+ */
114
+ export function applyFileEntry(client, name, entry, deps = {}) {
115
+ const r = resolve(deps);
116
+ if (client.managed !== 'file') {
117
+ return { ok: false, client: client.id, reason: 'cli_managed', message: `${client.label} is registered via the CLI, not a config file.`, path: client.configPath };
118
+ }
119
+ const p = client.configPath;
120
+ let existing = {};
121
+ let priorRaw = null;
122
+ if (r.fileExists(p)) {
123
+ priorRaw = r.readFile(p);
124
+ if (priorRaw.trim() !== '') {
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(priorRaw);
128
+ }
129
+ catch {
130
+ return {
131
+ ok: false,
132
+ client: client.id,
133
+ reason: 'parse_error',
134
+ message: `${p} is not valid JSON; not overwriting. Add this entry by hand:`,
135
+ path: p,
136
+ snippet: renderInstruction(client, name, entry).text,
137
+ };
138
+ }
139
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
140
+ return {
141
+ ok: false,
142
+ client: client.id,
143
+ reason: 'parse_error',
144
+ message: `${p} is not a JSON object; not overwriting. Add this entry by hand:`,
145
+ path: p,
146
+ snippet: renderInstruction(client, name, entry).text,
147
+ };
148
+ }
149
+ existing = parsed;
150
+ }
151
+ }
152
+ const prevServers = (existing.mcpServers && typeof existing.mcpServers === 'object' && !Array.isArray(existing.mcpServers))
153
+ ? existing.mcpServers
154
+ : {};
155
+ const had = Object.prototype.hasOwnProperty.call(prevServers, name);
156
+ const next = { ...existing, mcpServers: { ...prevServers, [name]: entry } };
157
+ r.mkdirp(pp(r).dirname(p));
158
+ // Back up the prior file before rewriting so a bad write is recoverable
159
+ // (these foreign configs are small and not concurrently written by us, so a
160
+ // backup + direct write is sufficient — our own config.json uses fs-atomic).
161
+ let backup;
162
+ if (priorRaw !== null) {
163
+ backup = `${p}.bak`;
164
+ r.writeFile(backup, priorRaw);
165
+ }
166
+ r.writeFile(p, `${JSON.stringify(next, null, 2)}\n`);
167
+ return { ok: true, client: client.id, action: had ? 'updated' : 'added', path: p, backup };
168
+ }
169
+ /** Resolve stdio-vs-http mode from the current transport config (B11).
170
+ * http/both → a `url` entry; stdio → a `command` entry. */
171
+ export function resolveMode(httpConfig) {
172
+ if (httpConfig && (httpConfig.transport === 'http' || httpConfig.transport === 'both')) {
173
+ return { mode: 'http', resourceUri: httpConfig.resourceUri };
174
+ }
175
+ return { mode: 'stdio' };
176
+ }
177
+ function argFlag(argv, name) {
178
+ const i = argv.indexOf(name);
179
+ return i >= 0 ? argv[i + 1] : undefined;
180
+ }
181
+ async function confirmTty(prompt) {
182
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
183
+ try {
184
+ const answer = await new Promise((resolve) => rl.question(prompt, resolve));
185
+ return /^y(es)?$/i.test(answer.trim());
186
+ }
187
+ finally {
188
+ rl.close();
189
+ }
190
+ }
191
+ /**
192
+ * CLI: `mcp-google-multi write-client-config [--client id] [--name n] [--url u]
193
+ * [--print] [--yes]`. Consent-gated (clig.dev): never writes a file-managed
194
+ * config without an explicit yes; non-TTY / `--print` prints the snippet.
195
+ */
196
+ export async function runWriteClientConfigCli(argv) {
197
+ const name = argFlag(argv, '--name') ?? DEFAULT_SERVER_NAME;
198
+ const only = argFlag(argv, '--client');
199
+ const printOnly = argv.includes('--print');
200
+ const yes = argv.includes('--yes');
201
+ const urlOverride = argFlag(argv, '--url');
202
+ let mode = 'stdio';
203
+ let resourceUri;
204
+ if (urlOverride) {
205
+ mode = 'http';
206
+ resourceUri = urlOverride.replace(/\/+$/, '');
207
+ }
208
+ else {
209
+ try {
210
+ const { resolveHttpConfig } = await import('./http-config.js');
211
+ const http = resolveHttpConfig();
212
+ const m = resolveMode(http);
213
+ mode = m.mode;
214
+ resourceUri = m.resourceUri;
215
+ }
216
+ catch {
217
+ // fall back to stdio on any config error
218
+ }
219
+ }
220
+ let entry;
221
+ try {
222
+ entry = buildServerEntry({ name, mode, resourceUri });
223
+ }
224
+ catch (e) {
225
+ console.error(e.message);
226
+ return 1;
227
+ }
228
+ let clients = detectClients();
229
+ if (only) {
230
+ clients = clients.filter((c) => c.id === only);
231
+ if (clients.length === 0) {
232
+ console.error(`Unknown --client "${only}". Known: claude-code, claude-desktop, cursor.`);
233
+ return 1;
234
+ }
235
+ }
236
+ else {
237
+ const present = clients.filter((c) => c.present);
238
+ if (present.length > 0)
239
+ clients = present;
240
+ }
241
+ const nonTty = !process.stdin.isTTY;
242
+ let wrote = 0;
243
+ for (const client of clients) {
244
+ const instr = renderInstruction(client, name, entry);
245
+ if (client.managed === 'cli') {
246
+ console.log(`\n${client.label}: run this command`);
247
+ console.log(` ${instr.text}`);
248
+ continue;
249
+ }
250
+ if (printOnly || (nonTty && !yes)) {
251
+ console.log(`\n${client.label}: add to ${instr.path}`);
252
+ console.log(instr.text);
253
+ if (nonTty && !yes && !printOnly)
254
+ console.log('(re-run with --yes to write this automatically)');
255
+ continue;
256
+ }
257
+ const ok = yes || (await confirmTty(`\n${client.label}: write ${instr.path}? [y/N] `));
258
+ if (!ok) {
259
+ console.log(`Skipped ${client.label}. Snippet for ${instr.path}:`);
260
+ console.log(instr.text);
261
+ continue;
262
+ }
263
+ const res = applyFileEntry(client, name, entry);
264
+ if (res.ok) {
265
+ wrote++;
266
+ console.log(`✔ ${client.label}: ${res.action} "${name}" in ${res.path}${res.backup ? ` (backup ${res.backup})` : ''}`);
267
+ }
268
+ else {
269
+ console.error(`✖ ${client.label}: ${res.message}`);
270
+ if (res.snippet)
271
+ console.error(res.snippet);
272
+ }
273
+ }
274
+ if (mode === 'http') {
275
+ console.log(`\nRemote HTTP server: ${resourceUri} — authentication is handled by the OAuth flow, so no secrets are stored in the client config.`);
276
+ }
277
+ else {
278
+ console.log('\nSecrets stay in ~/.config/mcp-google-multi/.env; the client entry carries none.');
279
+ }
280
+ void wrote;
281
+ return 0;
282
+ }
package/dist/client.js CHANGED
@@ -1,8 +1,15 @@
1
1
  import { OAuth2Client } from 'googleapis-common';
2
- import { ACCOUNT_CONFIG } from './accounts.js';
2
+ import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
3
3
  import { readToken, updateToken } from './token-store.js';
4
+ import { reauthHint } from './reauth-hint.js';
4
5
  export async function getClient(account) {
5
- const config = ACCOUNT_CONFIG[account];
6
+ // BR-7: lazy cross-process reload — one stat per dispatch, no watcher;
7
+ // reload failures keep the last-good registry, never kill the server.
8
+ refreshAccountSetIfStale();
9
+ const config = getAccountSet().configs[account];
10
+ if (!config) {
11
+ throw new Error(`Unknown account "${account}". Valid aliases: ${getAccountSet().aliases.join(', ')}`);
12
+ }
6
13
  if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
7
14
  throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
8
15
  'Check that .env exists in the project root or pass them as env vars.');
@@ -10,8 +17,7 @@ export async function getClient(account) {
10
17
  const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
11
18
  const tokenData = readToken(account);
12
19
  if (!tokenData) {
13
- throw new Error(`No token found for account "${account}" (${config.email}). ` +
14
- `Run: npx mcp-google-multi auth --account ${account}`);
20
+ throw new Error(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`);
15
21
  }
16
22
  oauth2Client.setCredentials(tokenData);
17
23
  oauth2Client.on('tokens', (tokens) => {
@@ -0,0 +1,38 @@
1
+ export declare const CONFIG_VERSION = 1;
2
+ export declare function configDir(env?: NodeJS.ProcessEnv): string;
3
+ export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
4
+ export declare const ALIAS_RE: RegExp;
5
+ export interface ConfigFile {
6
+ version: number;
7
+ accounts?: Record<string, {
8
+ email: string;
9
+ scopeProfile?: string;
10
+ admin?: boolean;
11
+ }>;
12
+ scopeProfiles?: Record<string, {
13
+ bundles: string[];
14
+ admin?: boolean;
15
+ includesBase?: boolean;
16
+ }>;
17
+ defaultAccount?: string;
18
+ discovery?: 'lazy' | 'curated' | 'eager';
19
+ toolsets?: string;
20
+ }
21
+ export declare function failStartup(slug: string, message: string): never;
22
+ export declare class ConfigFileError extends Error {
23
+ slug: string;
24
+ constructor(slug: string, message: string);
25
+ }
26
+ /**
27
+ * Load + validate config.json. Returns null when the file does not exist.
28
+ * onInvalid 'exit' is for boot/CLI only; runtime reload paths use 'throw' so a
29
+ * mid-edit or corrupt file can NEVER kill a running server (and so lock
30
+ * finally-cleanup still runs).
31
+ */
32
+ export declare function loadConfigFile(filePath?: string, onInvalid?: 'exit' | 'throw'): ConfigFile | null;
33
+ export declare function writeConfigFile(config: ConfigFile, filePath?: string): void;
34
+ /**
35
+ * Read-latest-under-lock mutation: acquire the lock, re-read the current file,
36
+ * apply the change to that (never a stale in-memory copy), write atomically.
37
+ */
38
+ export declare function mutateConfigFile(fn: (current: ConfigFile) => ConfigFile, filePath?: string): ConfigFile;