mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.4

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 (181) hide show
  1. package/README.md +5 -5
  2. package/dist/accounts.d.ts +20 -4
  3. package/dist/accounts.js +68 -44
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +20 -15
  6. package/dist/arg-normalize.js +19 -38
  7. package/dist/arg-strict.d.ts +7 -0
  8. package/dist/arg-strict.js +38 -28
  9. package/dist/auth.d.ts +5 -4
  10. package/dist/auth.js +16 -23
  11. package/dist/boot-gates.d.ts +26 -0
  12. package/dist/boot-gates.js +36 -0
  13. package/dist/cimd-cache.d.ts +38 -0
  14. package/dist/cimd-cache.js +111 -0
  15. package/dist/client-config.d.ts +2 -2
  16. package/dist/client-config.js +8 -14
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +0 -0
  19. package/dist/compose.d.ts +18 -0
  20. package/dist/compose.js +109 -0
  21. package/dist/config-file.d.ts +13 -0
  22. package/dist/config-file.js +41 -10
  23. package/dist/discover.js +7 -10
  24. package/dist/discovery-client.d.ts +3 -0
  25. package/dist/discovery-client.js +36 -32
  26. package/dist/doctor.d.ts +9 -5
  27. package/dist/doctor.js +119 -29
  28. package/dist/env-load.js +2 -5
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +6 -6
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +47 -22
  33. package/dist/fs-atomic.d.ts +8 -0
  34. package/dist/fs-atomic.js +216 -29
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +58 -0
  37. package/dist/http-config.d.ts +2 -2
  38. package/dist/http-config.js +3 -7
  39. package/dist/http-transport.d.ts +67 -9
  40. package/dist/http-transport.js +318 -52
  41. package/dist/identity.d.ts +6 -3
  42. package/dist/identity.js +24 -8
  43. package/dist/index.js +76 -160
  44. package/dist/master-key.d.ts +3 -3
  45. package/dist/master-key.js +16 -19
  46. package/dist/mcp-token.d.ts +126 -19
  47. package/dist/mcp-token.js +557 -76
  48. package/dist/metrics-cli.d.ts +1 -1
  49. package/dist/metrics-cli.js +1 -1
  50. package/dist/metrics-tap.js +2 -5
  51. package/dist/migrate-config.d.ts +1 -2
  52. package/dist/migrate-config.js +5 -7
  53. package/dist/net-tuning.js +2 -4
  54. package/dist/oauth-as.d.ts +104 -4
  55. package/dist/oauth-as.js +368 -114
  56. package/dist/oauth-consent.d.ts +10 -0
  57. package/dist/oauth-consent.js +20 -10
  58. package/dist/outbound-allowlist.js +9 -11
  59. package/dist/reauth-hint.d.ts +1 -1
  60. package/dist/reauth-hint.js +11 -10
  61. package/dist/registry-transfer.d.ts +5 -0
  62. package/dist/registry-transfer.js +63 -11
  63. package/dist/registry.d.ts +13 -4
  64. package/dist/registry.js +100 -72
  65. package/dist/scope-catalog.d.ts +16 -0
  66. package/dist/scope-catalog.js +40 -13
  67. package/dist/scope-observability.d.ts +10 -9
  68. package/dist/scope-observability.js +34 -24
  69. package/dist/services.d.ts +18 -10
  70. package/dist/services.js +61 -22
  71. package/dist/ssrf-guard.d.ts +23 -9
  72. package/dist/ssrf-guard.js +106 -78
  73. package/dist/tenant-purge.d.ts +26 -0
  74. package/dist/tenant-purge.js +65 -0
  75. package/dist/token-store.d.ts +20 -2
  76. package/dist/token-store.js +0 -0
  77. package/dist/tools/_coerce.d.ts +4 -4
  78. package/dist/tools/_errors.d.ts +3 -0
  79. package/dist/tools/_errors.js +72 -60
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +22 -8
  82. package/dist/tools/account-wizard.d.ts +13 -3
  83. package/dist/tools/account-wizard.js +107 -56
  84. package/dist/tools/accounts-tool.d.ts +8 -1
  85. package/dist/tools/accounts-tool.js +35 -19
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +14 -11
  88. package/dist/tools/analytics.d.ts +10 -1
  89. package/dist/tools/analytics.js +14 -10
  90. package/dist/tools/calendar.d.ts +2 -1
  91. package/dist/tools/calendar.js +19 -18
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +12 -8
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +22 -19
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +35 -36
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +80 -74
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +18 -12
  102. package/dist/tools/generated/_shared.d.ts +2 -1
  103. package/dist/tools/generated/_shared.js +7 -3
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +265 -265
  106. package/dist/tools/generated/analytics.d.ts +2 -1
  107. package/dist/tools/generated/analytics.js +125 -125
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +5 -5
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +55 -55
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +95 -95
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +209 -209
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +133 -133
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +99 -99
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +29 -29
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +71 -71
  124. package/dist/tools/generated/driveactivity.d.ts +2 -1
  125. package/dist/tools/generated/driveactivity.js +3 -3
  126. package/dist/tools/generated/drivelabels.d.ts +2 -1
  127. package/dist/tools/generated/drivelabels.js +53 -53
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +7 -7
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +117 -117
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +3 -3
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +7 -7
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/keep.d.ts +2 -1
  138. package/dist/tools/generated/keep.js +15 -15
  139. package/dist/tools/generated/licensing.d.ts +2 -1
  140. package/dist/tools/generated/licensing.js +15 -15
  141. package/dist/tools/generated/meet.d.ts +2 -1
  142. package/dist/tools/generated/meet.js +27 -27
  143. package/dist/tools/generated/postmaster.d.ts +2 -1
  144. package/dist/tools/generated/postmaster.js +9 -9
  145. package/dist/tools/generated/reseller.d.ts +2 -1
  146. package/dist/tools/generated/reseller.js +35 -35
  147. package/dist/tools/generated/script.d.ts +2 -1
  148. package/dist/tools/generated/script.js +33 -33
  149. package/dist/tools/generated/searchconsole.d.ts +2 -1
  150. package/dist/tools/generated/searchconsole.js +3 -3
  151. package/dist/tools/generated/sheets.d.ts +2 -1
  152. package/dist/tools/generated/sheets.js +15 -15
  153. package/dist/tools/generated/tasks.d.ts +2 -1
  154. package/dist/tools/generated/tasks.js +5 -5
  155. package/dist/tools/generated/vault.d.ts +2 -1
  156. package/dist/tools/generated/vault.js +67 -67
  157. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  158. package/dist/tools/generated/workspaceevents.js +31 -31
  159. package/dist/tools/gmail-mime.d.ts +1 -1
  160. package/dist/tools/gmail-mime.js +7 -12
  161. package/dist/tools/gmail.d.ts +4 -3
  162. package/dist/tools/gmail.js +67 -69
  163. package/dist/tools/google-api.d.ts +2 -3
  164. package/dist/tools/google-api.js +34 -22
  165. package/dist/tools/meet.d.ts +10 -1
  166. package/dist/tools/meet.js +12 -9
  167. package/dist/tools/searchconsole.d.ts +2 -1
  168. package/dist/tools/searchconsole.js +16 -13
  169. package/dist/tools/sheets.d.ts +2 -1
  170. package/dist/tools/sheets.js +35 -32
  171. package/dist/tools/slides.d.ts +10 -1
  172. package/dist/tools/slides.js +16 -10
  173. package/dist/tools/tasks.d.ts +2 -1
  174. package/dist/tools/tasks.js +18 -15
  175. package/dist/trim.d.ts +5 -0
  176. package/dist/trim.js +19 -0
  177. package/dist/usage-metrics.d.ts +23 -0
  178. package/dist/usage-metrics.js +67 -56
  179. package/dist/write-control.d.ts +4 -8
  180. package/dist/write-control.js +13 -6
  181. package/package.json +47 -21
@@ -1,15 +1,7 @@
1
- // Unknown-argument screening (backlog item 14). zod strips undeclared keys
2
- // before a handler runs, so a misremembered argument name used to produce a
3
- // SUCCESS response with wrong behavior: `parentId` on drive_create_folder was
4
- // dropped and the folder landed in My Drive root. Field-reported, then
5
- // reproduced: the typo, the correct name and a wholly invented key all
6
- // returned byte-identical responses.
7
- //
8
- // This module is pure and holds no registry reference. It never rewrites a key
9
- // and never touches a VALUE: it only decides that a key is undeclared, and
10
- // suggests what the caller probably meant. Rewriting would risk sending a real
11
- // value to Google on a guess, which is strictly worse than refusing.
12
- import { editDistance } from './scope-catalog.js';
1
+ // Unknown-argument screening: zod strips undeclared keys before a handler
2
+ // runs, so a misremembered name used to produce a SUCCESS with wrong behavior. See docs/internals.md.
3
+ import { editDistance, SUGGEST_MAX_INPUT } from './scope-catalog.js';
4
+ import { sliceClean } from './trim.js';
13
5
  /**
14
6
  * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
15
7
  * back to `warn`, not to the default: an operator who misspells the setting
@@ -25,24 +17,15 @@ export function unknownArgMode(env = process.env) {
25
17
  process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using warn\n`);
26
18
  return 'warn';
27
19
  }
28
- // The staged rollout is over: `warn` shipped first as pure observability, and
29
- // this is the flip it was staging for. An undeclared argument is dropped by
30
- // zod before the handler runs, so `warn` means the CLIENT sees a confident
31
- // success for a call the server did not perform: drive_list with a misspelled
32
- // folder key returned the My Drive root, byte-identical to no argument at all.
33
- // A wrong answer that reads as right is the failure mode agents recover from
34
- // worst, so the default refuses the call and says what it probably meant.
20
+ // `warn` means the client sees a confident success for a call the server
21
+ // didn't perform; a wrong answer that reads as right is the worst failure mode to recover from.
35
22
  const DEFAULT_MODE = 'reject';
36
23
  /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
37
24
  * the escape hatch is NOT one, because its open-endedness lives in the VALUES
38
25
  * of queryParams/body, never in its six fixed top-level keys. */
39
26
  export const STRICT_EXEMPT_TOOLS = new Set();
40
- /** Keys a CLIENT adds on its own, not keys the model chose. `random_string`
41
- * is the long-standing probe some clients send to a tool they read as taking
42
- * no arguments; the others are call-plumbing that bridges and proxies have
43
- * been observed to fold into `arguments` instead of `params._meta`, where the
44
- * spec puts them. Rejecting these would fail a call the model got right.
45
- * Measured against the full 978-tool surface: none is a declared key. */
27
+ // Keys a CLIENT adds on its own, not keys the model chose (a probe some
28
+ // clients send to argument-less tools, plus call-plumbing bridges fold into `arguments`).
46
29
  const CLIENT_ARTIFACT_KEYS = new Set([
47
30
  'random_string',
48
31
  'toolCallId',
@@ -59,6 +42,20 @@ export function isExemptKey(key) {
59
42
  * alone; they match far too much to be useful. */
60
43
  const GENERIC = new Set(['id', 'name', 'title', 'body', 'parent', 'text', 'content', 'type', 'value', 'key', 'data']);
61
44
  const flatten = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
45
+ /** A call can carry thousands of unknown keys, or one of megabytes: only the
46
+ * first few earn a did-you-mean, and the log line and the envelope echo a
47
+ * bounded prefix of each name. The reject decision itself is never capped. */
48
+ export const MAX_SUGGESTED_KEYS = 8;
49
+ const MAX_ECHO_CHARS = 64;
50
+ export function echoName(name) {
51
+ return name.length > MAX_ECHO_CHARS ? `${sliceClean(name, MAX_ECHO_CHARS)}...` : name;
52
+ }
53
+ /** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
54
+ export function echoNames(names, quote = false) {
55
+ const shown = names.slice(0, MAX_SUGGESTED_KEYS).map((n) => (quote ? `"${echoName(n)}"` : echoName(n)));
56
+ const rest = names.length - shown.length;
57
+ return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ');
58
+ }
62
59
  function tokens(s) {
63
60
  return s
64
61
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
@@ -76,6 +73,8 @@ function tokens(s) {
76
73
  * distance 6, well past any sane threshold, which is why tier 1 exists.
77
74
  */
78
75
  export function suggestKeys(unknown, declared, limit = 2) {
76
+ if (unknown.length > SUGGEST_MAX_INPUT)
77
+ return [];
79
78
  const uFlat = flatten(unknown);
80
79
  const uTok = tokens(unknown);
81
80
  const tiers = [[], [], [], []];
@@ -94,8 +93,12 @@ export function suggestKeys(unknown, declared, limit = 2) {
94
93
  tiers[2].push({ key, rank: Math.abs(kFlat.length - uFlat.length) });
95
94
  continue;
96
95
  }
96
+ const maxDist = Math.max(1, Math.floor(uFlat.length / 4));
97
+ // The distance is at least the length gap: skip the DP when that alone fails.
98
+ if (Math.abs(uFlat.length - kFlat.length) > maxDist)
99
+ continue;
97
100
  const d = editDistance(uFlat, kFlat);
98
- if (d <= Math.max(1, Math.floor(uFlat.length / 4)))
101
+ if (d <= maxDist)
99
102
  tiers[3].push({ key, rank: d });
100
103
  }
101
104
  const tier = tiers.find((t) => t.length > 0);
@@ -117,10 +120,17 @@ export function screenArguments(tool, args, declared) {
117
120
  if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
118
121
  return out;
119
122
  const declaredSet = new Set(declared);
123
+ // Tier 0 is a map lookup, so a redundant twin is recognised past the
124
+ // ranking budget too; a near-miss duplicate still needs that budget.
125
+ const byFlat = new Map();
126
+ for (const k of declared)
127
+ byFlat.set(flatten(k), [...(byFlat.get(flatten(k)) ?? []), k]);
128
+ let ranked = 0;
120
129
  for (const key of Object.keys(args)) {
121
130
  if (declaredSet.has(key) || isExemptKey(key))
122
131
  continue;
123
- const suggestions = suggestKeys(key, declared);
132
+ const twins = key.length <= SUGGEST_MAX_INPUT ? byFlat.get(flatten(key)) : undefined;
133
+ const suggestions = twins ? twins.slice(0, 2) : ranked++ < MAX_SUGGESTED_KEYS ? suggestKeys(key, declared) : [];
124
134
  // The caller sent both spellings, so the declared one already won and the
125
135
  // outcome is what it has always been. Never fail a call that works today.
126
136
  if (suggestions.some((s) => s in args)) {
@@ -133,7 +143,7 @@ export function screenArguments(tool, args, declared) {
133
143
  }
134
144
  /** The agent-facing envelope. Values are never included, only key names. */
135
145
  export function unknownArgEnvelope(tool, unknown, declared, account, siblings = []) {
136
- const names = unknown.map((u) => `"${u.sent}"`).join(', ');
146
+ const names = echoNames(unknown.map((u) => u.sent), true);
137
147
  const accepts = `This tool accepts: ${declared.join(', ')}.`;
138
148
  const suggested = unknown.flatMap((u) => u.suggestions);
139
149
  let hint;
package/dist/auth.d.ts CHANGED
@@ -1,17 +1,18 @@
1
+ import type { AccountSet } from './accounts.js';
1
2
  import { ADMIN_SCOPES } from './scope-catalog.js';
2
3
  export declare const BASE_SCOPES: string[];
3
4
  export declare const OPTIONAL_SCOPE_BUNDLES: Record<string, string[]>;
4
5
  export { ADMIN_SCOPES };
5
6
  /** Union of every account's resolved bundles: a service registers if ANY
6
- * account can authorize it; per-account authz happens at call time (BR2). */
7
- export declare function getOptionalBundles(): string[];
7
+ * account can authorize it; per-account authz happens at call time. */
8
+ export declare function getOptionalBundles(set?: AccountSet): string[];
8
9
  /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
9
10
  * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
10
11
  * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
11
- export declare function getAdminAccounts(): string[];
12
+ export declare function getAdminAccounts(set?: AccountSet): string[];
12
13
  /** Scopes are fixed at consent time: changing an account's profile (or the
13
14
  * legacy env) changes its consent set and requires re-running auth. Evaluated
14
15
  * per account: `work` can carry admin + gmail_settings while `personal` is
15
16
  * never asked for them. */
16
- export declare function resolveScopesForAccount(alias: string): string[];
17
+ export declare function resolveScopesForAccount(alias: string, set?: AccountSet): string[];
17
18
  export declare function runAuthFlow(args: string[]): Promise<void>;
package/dist/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { openUrl } from './open-url.js';
3
3
  import { ACCOUNTS, getAccountSet } from './accounts.js';
4
- import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
4
+ import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
5
5
  import { resolveMasterKey } from './master-key.js';
6
6
  import { writeToken } from './token-store.js';
7
7
  import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
@@ -31,12 +31,8 @@ function parseCsvEnv(name) {
31
31
  .map(s => s.trim())
32
32
  .filter(Boolean);
33
33
  }
34
- /**
35
- * Legacy global override (BC7): a set GOOGLE_OPTIONAL_SCOPES acts as an
36
- * implicit "legacy-global" profile applied to every account (env wins over
37
- * file profiles, cc-config R2). Unknown names now fail loudly (BR3) where v5
38
- * silently dropped them — the intended migration signal.
39
- */
34
+ // A set GOOGLE_OPTIONAL_SCOPES acts as an implicit profile applied to every
35
+ // account (env wins over file profiles); unknown names fail loudly.
40
36
  function legacyGlobalProfile() {
41
37
  const names = parseCsvEnv('GOOGLE_OPTIONAL_SCOPES');
42
38
  if (names.length === 0)
@@ -48,33 +44,31 @@ function legacyGlobalProfile() {
48
44
  if (bundle === 'admin') {
49
45
  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.');
50
46
  }
51
- if (!(bundle in BUNDLE_CATALOG)) {
47
+ if (!isKnownBundle(bundle)) {
52
48
  const hint = closestBundle(bundle);
53
49
  throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
54
50
  }
55
51
  }
56
52
  return { bundles };
57
53
  }
58
- function profileForAccount(alias) {
54
+ function profileForAccount(alias, set = getAccountSet()) {
59
55
  const legacy = legacyGlobalProfile();
60
56
  if (legacy)
61
57
  return legacy;
62
- const set = getAccountSet();
63
58
  const name = set.configs[alias]?.scopeProfile ?? 'base';
64
59
  // hasOwn: a profile named like an Object.prototype member must never
65
60
  // resolve to the inherited function.
66
61
  return Object.hasOwn(set.scopeProfiles, name) ? set.scopeProfiles[name] : { bundles: [] };
67
62
  }
68
63
  /** Union of every account's resolved bundles: a service registers if ANY
69
- * account can authorize it; per-account authz happens at call time (BR2). */
70
- export function getOptionalBundles() {
64
+ * account can authorize it; per-account authz happens at call time. */
65
+ export function getOptionalBundles(set = getAccountSet()) {
71
66
  const legacy = legacyGlobalProfile();
72
67
  if (legacy)
73
68
  return legacy.bundles.filter(b => b !== 'admin');
74
69
  const union = new Set();
75
- const set = getAccountSet();
76
70
  for (const alias of set.aliases) {
77
- for (const b of profileForAccount(alias).bundles) {
71
+ for (const b of profileForAccount(alias, set).bundles) {
78
72
  if (b !== 'admin')
79
73
  union.add(b);
80
74
  }
@@ -84,12 +78,12 @@ export function getOptionalBundles() {
84
78
  /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
85
79
  * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
86
80
  * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
87
- export function getAdminAccounts() {
88
- const { aliases, configs } = getAccountSet();
81
+ export function getAdminAccounts(set = getAccountSet()) {
82
+ const { aliases, configs } = set;
89
83
  return aliases.filter((a) => {
90
84
  if (configs[a].admin === true)
91
85
  return true;
92
- const p = profileForAccount(a);
86
+ const p = profileForAccount(a, set);
93
87
  return p.admin === true || p.bundles.includes('admin');
94
88
  });
95
89
  }
@@ -97,15 +91,15 @@ export function getAdminAccounts() {
97
91
  * legacy env) changes its consent set and requires re-running auth. Evaluated
98
92
  * per account: `work` can carry admin + gmail_settings while `personal` is
99
93
  * never asked for them. */
100
- export function resolveScopesForAccount(alias) {
101
- const profile = profileForAccount(alias);
94
+ export function resolveScopesForAccount(alias, set = getAccountSet()) {
95
+ const profile = profileForAccount(alias, set);
102
96
  const scopes = profile.includesBase === false ? [] : [...BASE_SCOPES];
103
97
  for (const bundle of profile.bundles) {
104
98
  if (bundle === 'admin')
105
99
  continue;
106
100
  scopes.push(...BUNDLE_CATALOG[bundle].scopes);
107
101
  }
108
- if (getAdminAccounts().includes(alias)) {
102
+ if (getAdminAccounts(set).includes(alias)) {
109
103
  scopes.push(...ADMIN_SCOPES);
110
104
  }
111
105
  return Array.from(new Set(scopes));
@@ -132,9 +126,8 @@ export async function runAuthFlow(args) {
132
126
  // Auto-provisions on a fresh install (env > keychain > file > generate);
133
127
  // resolves eagerly so a provisioning failure surfaces before the browser opens.
134
128
  resolveMasterKey();
135
- // Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
136
- // build the auth URL from the assigned redirect. CLI gets a patient timeout;
137
- // errors propagate to main()'s fatal handler like every other CLI failure.
129
+ // Listen first, then build the auth URL from the assigned redirect; errors
130
+ // propagate to main()'s fatal handler like every other CLI failure.
138
131
  const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
139
132
  const oauth2Client = buildConsentClient(loop.redirect);
140
133
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
@@ -0,0 +1,26 @@
1
+ export interface MtBootGates {
2
+ /** True = multi-tenant deployment: the server boots with zero accounts
3
+ * (tenants provision in-band) and the provisioning gate below replaces the
4
+ * flat owner-allowlist requirement. */
5
+ multiTenant: boolean;
6
+ /** Fail-fast replacement for the E_OWNER_EMAILS_REQUIRED check. MUST refuse
7
+ * (exit/throw) when no valid provisioning mechanism is configured — an
8
+ * ungated HTTP endpoint must never boot, whatever the condition checked. */
9
+ assertProvisioningGate: () => void;
10
+ }
11
+ export declare function setMtBootGates(g: MtBootGates | null): void;
12
+ export declare function mtBootGates(): MtBootGates | null;
13
+ export declare function isMultiTenantBoot(): boolean;
14
+ /** The empty-registry guard applies to stdio always, and to HTTP only when single-owner: a
15
+ * multi-tenant HTTP deploy starts empty by design. */
16
+ export declare function accountsAssertRequired(wantStdio: boolean, multiTenant: boolean): boolean;
17
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
18
+ * the env registry is process-wide, so it would graft one operator's accounts
19
+ * onto every tenant and turn every tenant's first wizard call into a dead
20
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
21
+ * of one clear one. */
22
+ export declare function assertNoEnvAccountsMode(env?: NodeJS.ProcessEnv): void;
23
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
24
+ * global scope profile, so on a multi-tenant box one operator's scope choice
25
+ * would silently apply to every tenant's consent. */
26
+ export declare function assertNoEnvOptionalScopesMode(env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,36 @@
1
+ // Multi-tenant boot gates: every helper reduces to single-owner behavior when
2
+ // no gates are installed. Deliberately side-effect-free, unlike accounts.ts/auth.ts.
3
+ import { failStartup } from './config-file.js';
4
+ let gates = null;
5
+ export function setMtBootGates(g) {
6
+ gates = g;
7
+ }
8
+ export function mtBootGates() {
9
+ return gates;
10
+ }
11
+ export function isMultiTenantBoot() {
12
+ return gates?.multiTenant === true;
13
+ }
14
+ /** The empty-registry guard applies to stdio always, and to HTTP only when single-owner: a
15
+ * multi-tenant HTTP deploy starts empty by design. */
16
+ export function accountsAssertRequired(wantStdio, multiTenant) {
17
+ return wantStdio || !multiTenant;
18
+ }
19
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
20
+ * the env registry is process-wide, so it would graft one operator's accounts
21
+ * onto every tenant and turn every tenant's first wizard call into a dead
22
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
23
+ * of one clear one. */
24
+ export function assertNoEnvAccountsMode(env = process.env) {
25
+ if (env.GOOGLE_ACCOUNTS?.trim()) {
26
+ failStartup('E_ENV_ACCOUNTS_MODE_MT', 'GOOGLE_ACCOUNTS must not be set on a multi-tenant deployment; accounts are linked in-band per tenant. Unset it and restart.');
27
+ }
28
+ }
29
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
30
+ * global scope profile, so on a multi-tenant box one operator's scope choice
31
+ * would silently apply to every tenant's consent. */
32
+ export function assertNoEnvOptionalScopesMode(env = process.env) {
33
+ if (env.GOOGLE_OPTIONAL_SCOPES?.trim()) {
34
+ failStartup('E_ENV_OPTIONAL_SCOPES_MT', 'GOOGLE_OPTIONAL_SCOPES must not be set on a multi-tenant deployment; scopes are chosen per tenant at consent. Unset it and restart.');
35
+ }
36
+ }
@@ -0,0 +1,38 @@
1
+ export declare const CIMD_TTL_MS: number;
2
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
3
+ export declare const CIMD_MAX_STALE_MS: number;
4
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
5
+ export declare const CIMD_MAX_IN_FLIGHT = 4;
6
+ export declare const CIMD_CACHE_MAX = 256;
7
+ export type CimdDocument = Record<string, unknown>;
8
+ type CimdError = {
9
+ error: 'E_CIMD_SSRF_BLOCKED' | 'E_CIMD_INVALID';
10
+ };
11
+ /** `fresh` is true only for the request whose fetch produced the document. */
12
+ export type CimdResolution = {
13
+ doc: CimdDocument;
14
+ fresh: boolean;
15
+ } | {
16
+ busy: true;
17
+ } | CimdError;
18
+ export interface CimdCacheDeps {
19
+ fetch: (clientId: string) => Promise<CimdDocument>;
20
+ now: () => number;
21
+ log: (line: string) => void;
22
+ }
23
+ export declare class CimdClientCache {
24
+ private readonly deps;
25
+ private readonly entries;
26
+ private readonly inFlight;
27
+ private busyLoggedAt;
28
+ private busySuppressed;
29
+ constructor(deps: CimdCacheDeps);
30
+ get size(): number;
31
+ /** `validate` runs once per fetch, in the request that started it, and every
32
+ * request waiting on that fetch gets the same answer. */
33
+ resolve(clientId: string, validate: (doc: CimdDocument) => boolean): Promise<CimdResolution>;
34
+ private fetchFor;
35
+ private store;
36
+ private noteBusy;
37
+ }
38
+ export {};
@@ -0,0 +1,111 @@
1
+ // Bounds the unauthenticated-fetch surface of GET /authorize: capped
2
+ // in-flight fetches, one fetch per client_id, no error caching. See docs/internals.md.
3
+ import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
4
+ import { logSafe } from './trim.js';
5
+ export const CIMD_TTL_MS = 5 * 60_000;
6
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
7
+ export const CIMD_MAX_STALE_MS = 24 * 60 * 60_000;
8
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
9
+ export const CIMD_MAX_IN_FLIGHT = 4;
10
+ export const CIMD_CACHE_MAX = 256;
11
+ const BUSY_LOG_EVERY_MS = 60_000;
12
+ export class CimdClientCache {
13
+ deps;
14
+ entries = new Map();
15
+ // One entry per running fetch, so its size is the number of permits taken.
16
+ inFlight = new Map();
17
+ busyLoggedAt = -Infinity;
18
+ busySuppressed = 0;
19
+ constructor(deps) {
20
+ this.deps = deps;
21
+ }
22
+ get size() {
23
+ return this.entries.size;
24
+ }
25
+ /** `validate` runs once per fetch, in the request that started it, and every
26
+ * request waiting on that fetch gets the same answer. */
27
+ async resolve(clientId, validate) {
28
+ const entry = this.entries.get(clientId);
29
+ const age = entry ? this.deps.now() - entry.at : 0;
30
+ if (entry && age < CIMD_TTL_MS)
31
+ return { doc: entry.doc, fresh: false };
32
+ if (entry && age >= CIMD_MAX_STALE_MS)
33
+ this.entries.delete(clientId);
34
+ const running = this.inFlight.get(clientId);
35
+ if (running) {
36
+ const out = await running;
37
+ return 'doc' in out ? { doc: out.doc, fresh: false } : out;
38
+ }
39
+ if (this.inFlight.size >= CIMD_MAX_IN_FLIGHT) {
40
+ // A flood of fresh paths is exactly when the cap is full, and a client
41
+ // that signed in before must still be able to.
42
+ const stale = this.entries.get(clientId);
43
+ if (stale)
44
+ return { doc: stale.doc, fresh: false };
45
+ this.noteBusy();
46
+ return { busy: true };
47
+ }
48
+ return this.fetchFor(clientId, validate);
49
+ }
50
+ async fetchFor(clientId, validate) {
51
+ // Never rejects: neither core nor a host installs an unhandledRejection
52
+ // handler, and a waiter that stopped listening must not take the process down.
53
+ let settle;
54
+ this.inFlight.set(clientId, new Promise((r) => (settle = r)));
55
+ let out = { error: 'E_CIMD_INVALID' };
56
+ try {
57
+ const doc = await this.deps.fetch(clientId);
58
+ if (validate(doc)) {
59
+ this.store(clientId, doc);
60
+ out = { doc, fresh: true };
61
+ }
62
+ else {
63
+ this.entries.delete(clientId);
64
+ }
65
+ }
66
+ catch (e) {
67
+ out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
68
+ // stale-if-error: an issuer that's down/overloaded says nothing about
69
+ // the document, so the last good one stays and serves this fetch too.
70
+ const stale = this.entries.get(clientId);
71
+ if (!saysNothingAboutDocument(e))
72
+ this.entries.delete(clientId);
73
+ else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
74
+ out = { doc: stale.doc, fresh: false };
75
+ // The detail goes to the log only; the caller gets a generic answer.
76
+ this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
77
+ }
78
+ finally {
79
+ this.inFlight.delete(clientId);
80
+ settle(out);
81
+ }
82
+ return out;
83
+ }
84
+ store(clientId, doc) {
85
+ this.entries.delete(clientId);
86
+ while (this.entries.size >= CIMD_CACHE_MAX) {
87
+ const oldest = this.entries.keys().next().value;
88
+ if (oldest === undefined)
89
+ break;
90
+ this.entries.delete(oldest);
91
+ }
92
+ this.entries.set(clientId, { doc, at: this.deps.now() });
93
+ }
94
+ // Over-cap requests arrive at whatever rate the caller likes.
95
+ noteBusy() {
96
+ const now = this.deps.now();
97
+ if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
98
+ this.busySuppressed++;
99
+ return;
100
+ }
101
+ const suppressed = this.busySuppressed;
102
+ this.busyLoggedAt = now;
103
+ this.busySuppressed = 0;
104
+ this.deps.log(`CIMD busy: in-flight cap reached${suppressed > 0 ? ` (n=${suppressed} suppressed)` : ''}`);
105
+ }
106
+ }
107
+ function saysNothingAboutDocument(e) {
108
+ if (isTransientFetchError(e))
109
+ return true;
110
+ return e instanceof CimdHttpError && (e.status === 408 || e.status === 429 || e.status >= 500);
111
+ }
@@ -38,7 +38,7 @@ export interface EntryOptions {
38
38
  }
39
39
  /**
40
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
41
+ * its own .env from ~/.config/mcp-google-multi, so we never
42
42
  * inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
43
43
  */
44
44
  export declare function buildServerEntry(opts: EntryOptions): ServerEntry;
@@ -73,7 +73,7 @@ export type ApplyResult = {
73
73
  * the prior file, update-in-place if the key exists.
74
74
  */
75
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).
76
+ /** Resolve stdio-vs-http mode from the current transport config.
77
77
  * http/both → a `url` entry; stdio → a `command` entry. */
78
78
  export declare function resolveMode(httpConfig?: {
79
79
  transport: 'stdio' | 'http' | 'both';
@@ -1,9 +1,5 @@
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).
1
+ // Registers the server with the user's MCP client so they never hand-edit
2
+ // JSON. Pure core (detection + entry building + idempotent apply), deps-injectable for tests.
7
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
4
  import * as readline from 'node:readline';
9
5
  import path from 'node:path';
@@ -20,9 +16,8 @@ function resolve(deps) {
20
16
  mkdirp: deps.mkdirp ?? ((dir) => mkdirSync(dir, { recursive: true })),
21
17
  };
22
18
  }
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).
19
+ // Build paths for the TARGET platform's separators, not the running host's,
20
+ // so the injected `platform` is honored deterministically.
26
21
  function pp(r) {
27
22
  return r.platform === 'win32' ? path.win32 : path.posix;
28
23
  }
@@ -78,7 +73,7 @@ export function detectClients(deps = {}) {
78
73
  }
79
74
  /**
80
75
  * 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
76
+ * its own .env from ~/.config/mcp-google-multi, so we never
82
77
  * inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
83
78
  */
84
79
  export function buildServerEntry(opts) {
@@ -155,9 +150,8 @@ export function applyFileEntry(client, name, entry, deps = {}) {
155
150
  const had = Object.prototype.hasOwnProperty.call(prevServers, name);
156
151
  const next = { ...existing, mcpServers: { ...prevServers, [name]: entry } };
157
152
  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).
153
+ // Back up before rewriting so a bad write is recoverable; our own
154
+ // config.json uses fs-atomic, but these small foreign configs don't need it.
161
155
  let backup;
162
156
  if (priorRaw !== null) {
163
157
  backup = `${p}.bak`;
@@ -166,7 +160,7 @@ export function applyFileEntry(client, name, entry, deps = {}) {
166
160
  r.writeFile(p, `${JSON.stringify(next, null, 2)}\n`);
167
161
  return { ok: true, client: client.id, action: had ? 'updated' : 'added', path: p, backup };
168
162
  }
169
- /** Resolve stdio-vs-http mode from the current transport config (B11).
163
+ /** Resolve stdio-vs-http mode from the current transport config.
170
164
  * http/both → a `url` entry; stdio → a `command` entry. */
171
165
  export function resolveMode(httpConfig) {
172
166
  if (httpConfig && (httpConfig.transport === 'http' || httpConfig.transport === 'both')) {
package/dist/client.d.ts CHANGED
@@ -1,3 +1,33 @@
1
1
  import { OAuth2Client } from 'googleapis-common';
2
- import type { Account } from './accounts.js';
3
- export declare function getClient(account: Account): Promise<OAuth2Client>;
2
+ import type { Account, AccountSet } from './accounts.js';
3
+ import type { TokenData } from './types.js';
4
+ export interface GetClientDeps {
5
+ /** Live account view — read at call time, never a captured snapshot. */
6
+ accounts: () => AccountSet;
7
+ readToken: (alias: string) => TokenData | null;
8
+ updateToken: (alias: string, updates: object) => void;
9
+ /** Cross-process staleness probe (single-owner global registry only). */
10
+ refreshIfStale?: () => void;
11
+ }
12
+ /** Test-only visibility into the dedupe map. */
13
+ export declare function __inflightSizeForTest(): number;
14
+ /**
15
+ * Per-context client factory: `contextKey` namespaces the single-flight map
16
+ * (one lane per tenant), `deps` carry that context's account view and token
17
+ * custody. The free core builds exactly one, bound to the global registry.
18
+ */
19
+ export declare function makeGetClient(contextKey: string, deps: GetClientDeps): (account: Account) => Promise<OAuth2Client>;
20
+ /** Injection surface for curated tool files: absent = the single-owner
21
+ * getClient below (today's behavior); a tenant context passes its own. */
22
+ export interface CuratedToolDeps {
23
+ getClientFn?: typeof getClient;
24
+ /** Whether tools may read or write the server host's disk (local uploads,
25
+ * downloads, attachments). Absent = true; the host belongs to its operator,
26
+ * so a non-owner context passes false. */
27
+ localFiles?: boolean;
28
+ }
29
+ /** The single-owner client, bound to the global registry + token store. The
30
+ * operator surfaces that never carry a tenant (api-probe, executor and
31
+ * escape-hatch `deps.getClientFn ?? getClient` defaults) resolve through
32
+ * this. */
33
+ export declare const getClient: (account: Account) => Promise<OAuth2Client>;
package/dist/client.js CHANGED
Binary file
@@ -0,0 +1,18 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import type { ServerTarget } from './http-transport.js';
3
+ import { ToolRegistry, type DiscoveryMode } from './registry.js';
4
+ import { type IdentityContext } from './identity.js';
5
+ import type { Metrics } from './usage-metrics.js';
6
+ export interface McpServerOptions {
7
+ /** Sent in the initialize result; omitted entirely when absent or blank. */
8
+ instructions?: string;
9
+ }
10
+ /** The one place the MCP server is constructed, so an embedding host can pass
11
+ * per-session `instructions` without re-deriving the server identity. */
12
+ export declare function createMcpServer(version: string, opts?: McpServerOptions): McpServer;
13
+ export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
14
+ /** The per-request hooks bound to one registry: argument shapes, unknown-
15
+ * argument screening and the account a validation envelope names. Both
16
+ * transports and every resolved server target build them here, so a mistyped
17
+ * argument behaves the same wherever it arrives. */
18
+ export declare function requestHooksFor(registry: ToolRegistry, ctx: Pick<IdentityContext, 'accounts'>, metrics?: Metrics | null, env?: NodeJS.ProcessEnv): Omit<ServerTarget, 'server'>;