mcp-google-multi 6.0.0-beta.1 → 6.0.0-beta.3

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 (182) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +22 -1
  3. package/dist/accounts.js +64 -16
  4. package/dist/api-probe.d.ts +5 -0
  5. package/dist/api-probe.js +23 -15
  6. package/dist/arg-normalize.d.ts +61 -4
  7. package/dist/arg-normalize.js +229 -4
  8. package/dist/arg-strict.d.ts +61 -0
  9. package/dist/arg-strict.js +193 -0
  10. package/dist/auth.d.ts +4 -3
  11. package/dist/auth.js +26 -85
  12. package/dist/boot-gates.d.ts +26 -0
  13. package/dist/boot-gates.js +38 -0
  14. package/dist/cimd-cache.d.ts +38 -0
  15. package/dist/cimd-cache.js +117 -0
  16. package/dist/client.d.ts +32 -2
  17. package/dist/client.js +0 -0
  18. package/dist/compose.d.ts +11 -0
  19. package/dist/compose.js +109 -0
  20. package/dist/config-file.d.ts +14 -0
  21. package/dist/config-file.js +41 -1
  22. package/dist/discover.js +38 -15
  23. package/dist/discovery-client.d.ts +11 -1
  24. package/dist/discovery-client.js +120 -18
  25. package/dist/doctor.d.ts +15 -3
  26. package/dist/doctor.js +151 -27
  27. package/dist/env-load.d.ts +11 -0
  28. package/dist/env-load.js +23 -1
  29. package/dist/executor.d.ts +3 -0
  30. package/dist/executor.js +16 -3
  31. package/dist/fanout.d.ts +9 -1
  32. package/dist/fanout.js +83 -14
  33. package/dist/fs-atomic.d.ts +11 -0
  34. package/dist/fs-atomic.js +219 -28
  35. package/dist/hint-provider.d.ts +129 -0
  36. package/dist/hint-provider.js +64 -0
  37. package/dist/http-transport.d.ts +73 -6
  38. package/dist/http-transport.js +350 -26
  39. package/dist/identity.d.ts +4 -1
  40. package/dist/identity.js +25 -6
  41. package/dist/index.js +142 -114
  42. package/dist/master-key.js +13 -5
  43. package/dist/mcp-token.d.ts +136 -19
  44. package/dist/mcp-token.js +569 -72
  45. package/dist/metrics-cli.d.ts +21 -0
  46. package/dist/metrics-cli.js +206 -0
  47. package/dist/metrics-tap.d.ts +12 -0
  48. package/dist/metrics-tap.js +95 -0
  49. package/dist/migrate-config.js +2 -2
  50. package/dist/oauth-as.d.ts +103 -3
  51. package/dist/oauth-as.js +374 -79
  52. package/dist/oauth-consent.d.ts +35 -10
  53. package/dist/oauth-consent.js +101 -39
  54. package/dist/outbound-allowlist.d.ts +34 -0
  55. package/dist/outbound-allowlist.js +111 -0
  56. package/dist/reauth-hint.d.ts +1 -1
  57. package/dist/reauth-hint.js +12 -8
  58. package/dist/registry-transfer.d.ts +5 -0
  59. package/dist/registry-transfer.js +65 -5
  60. package/dist/registry.d.ts +66 -2
  61. package/dist/registry.js +214 -18
  62. package/dist/scope-catalog.d.ts +11 -0
  63. package/dist/scope-catalog.js +39 -6
  64. package/dist/scope-observability.d.ts +8 -6
  65. package/dist/scope-observability.js +29 -13
  66. package/dist/services.d.ts +17 -4
  67. package/dist/services.js +77 -6
  68. package/dist/setup-prompt.d.ts +1 -1
  69. package/dist/setup-prompt.js +1 -1
  70. package/dist/ssrf-guard.d.ts +23 -9
  71. package/dist/ssrf-guard.js +101 -62
  72. package/dist/tenant-purge.d.ts +26 -0
  73. package/dist/tenant-purge.js +67 -0
  74. package/dist/token-store.d.ts +20 -2
  75. package/dist/token-store.js +0 -0
  76. package/dist/tools/_coerce.d.ts +5 -0
  77. package/dist/tools/_coerce.js +11 -0
  78. package/dist/tools/_errors.d.ts +34 -3
  79. package/dist/tools/_errors.js +346 -53
  80. package/dist/tools/_local-files.d.ts +9 -0
  81. package/dist/tools/_local-files.js +25 -2
  82. package/dist/tools/account-wizard.d.ts +34 -2
  83. package/dist/tools/account-wizard.js +223 -65
  84. package/dist/tools/accounts-tool.d.ts +7 -0
  85. package/dist/tools/accounts-tool.js +34 -18
  86. package/dist/tools/admin.d.ts +10 -1
  87. package/dist/tools/admin.js +21 -16
  88. package/dist/tools/analytics.d.ts +27 -0
  89. package/dist/tools/analytics.js +289 -0
  90. package/dist/tools/calendar.d.ts +9 -1
  91. package/dist/tools/calendar.js +93 -29
  92. package/dist/tools/chat.d.ts +10 -1
  93. package/dist/tools/chat.js +20 -12
  94. package/dist/tools/contacts.d.ts +2 -1
  95. package/dist/tools/contacts.js +74 -43
  96. package/dist/tools/docs.d.ts +2 -1
  97. package/dist/tools/docs.js +90 -84
  98. package/dist/tools/drive.d.ts +2 -1
  99. package/dist/tools/drive.js +197 -120
  100. package/dist/tools/forms.d.ts +10 -1
  101. package/dist/tools/forms.js +26 -19
  102. package/dist/tools/generated/_shared.d.ts +8 -1
  103. package/dist/tools/generated/_shared.js +18 -4
  104. package/dist/tools/generated/admin.d.ts +2 -1
  105. package/dist/tools/generated/admin.js +610 -479
  106. package/dist/tools/generated/analytics.d.ts +3 -0
  107. package/dist/tools/generated/analytics.js +981 -0
  108. package/dist/tools/generated/appsmarket.d.ts +2 -1
  109. package/dist/tools/generated/appsmarket.js +9 -9
  110. package/dist/tools/generated/calendar.d.ts +2 -1
  111. package/dist/tools/generated/calendar.js +83 -83
  112. package/dist/tools/generated/chat.d.ts +2 -1
  113. package/dist/tools/generated/chat.js +178 -151
  114. package/dist/tools/generated/classroom.d.ts +2 -1
  115. package/dist/tools/generated/classroom.js +467 -425
  116. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  117. package/dist/tools/generated/cloudidentity.js +209 -201
  118. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  119. package/dist/tools/generated/cloudsearch.js +139 -138
  120. package/dist/tools/generated/contacts.d.ts +2 -1
  121. package/dist/tools/generated/contacts.js +49 -40
  122. package/dist/tools/generated/drive.d.ts +2 -1
  123. package/dist/tools/generated/drive.js +133 -124
  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 +113 -85
  128. package/dist/tools/generated/forms.d.ts +2 -1
  129. package/dist/tools/generated/forms.js +14 -14
  130. package/dist/tools/generated/gmail.d.ts +2 -1
  131. package/dist/tools/generated/gmail.js +253 -226
  132. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  133. package/dist/tools/generated/groupsmigration.js +4 -4
  134. package/dist/tools/generated/groupssettings.d.ts +2 -1
  135. package/dist/tools/generated/groupssettings.js +10 -10
  136. package/dist/tools/generated/index.d.ts +2 -1
  137. package/dist/tools/generated/index.js +2 -0
  138. package/dist/tools/generated/keep.d.ts +2 -1
  139. package/dist/tools/generated/keep.js +23 -22
  140. package/dist/tools/generated/licensing.d.ts +2 -1
  141. package/dist/tools/generated/licensing.js +54 -38
  142. package/dist/tools/generated/meet.d.ts +2 -1
  143. package/dist/tools/generated/meet.js +41 -41
  144. package/dist/tools/generated/method-map.d.ts +2 -0
  145. package/dist/tools/generated/method-map.js +894 -0
  146. package/dist/tools/generated/postmaster.d.ts +2 -1
  147. package/dist/tools/generated/postmaster.js +12 -12
  148. package/dist/tools/generated/reseller.d.ts +2 -1
  149. package/dist/tools/generated/reseller.js +69 -63
  150. package/dist/tools/generated/script.d.ts +2 -1
  151. package/dist/tools/generated/script.js +62 -52
  152. package/dist/tools/generated/searchconsole.d.ts +2 -1
  153. package/dist/tools/generated/searchconsole.js +8 -6
  154. package/dist/tools/generated/sheets.d.ts +2 -1
  155. package/dist/tools/generated/sheets.js +25 -24
  156. package/dist/tools/generated/tasks.d.ts +2 -1
  157. package/dist/tools/generated/tasks.js +15 -9
  158. package/dist/tools/generated/vault.d.ts +2 -1
  159. package/dist/tools/generated/vault.js +132 -123
  160. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  161. package/dist/tools/generated/workspaceevents.js +48 -47
  162. package/dist/tools/gmail.d.ts +12 -2
  163. package/dist/tools/gmail.js +95 -51
  164. package/dist/tools/google-api.d.ts +2 -3
  165. package/dist/tools/google-api.js +76 -33
  166. package/dist/tools/meet.d.ts +10 -1
  167. package/dist/tools/meet.js +18 -11
  168. package/dist/tools/searchconsole.d.ts +2 -1
  169. package/dist/tools/searchconsole.js +27 -23
  170. package/dist/tools/sheets.d.ts +2 -1
  171. package/dist/tools/sheets.js +72 -80
  172. package/dist/tools/slides.d.ts +10 -1
  173. package/dist/tools/slides.js +23 -16
  174. package/dist/tools/tasks.d.ts +2 -1
  175. package/dist/tools/tasks.js +35 -31
  176. package/dist/trim.d.ts +26 -0
  177. package/dist/trim.js +67 -0
  178. package/dist/usage-metrics.d.ts +178 -0
  179. package/dist/usage-metrics.js +621 -0
  180. package/dist/write-control.d.ts +27 -1
  181. package/dist/write-control.js +99 -12
  182. package/package.json +34 -3
package/dist/auth.js CHANGED
@@ -1,12 +1,10 @@
1
- import { OAuth2Client } from 'googleapis-common';
2
- import http from 'node:http';
3
- import { URL } from 'node:url';
4
1
  import { randomBytes } from 'node:crypto';
5
2
  import { openUrl } from './open-url.js';
6
3
  import { ACCOUNTS, getAccountSet } from './accounts.js';
7
- import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
4
+ import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
8
5
  import { resolveMasterKey } from './master-key.js';
9
6
  import { writeToken } from './token-store.js';
7
+ import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
10
8
  // Personal (non-Workspace) accounts 403 on admin scopes; ADMIN_SCOPES stays per-account opt-in, never granted by default.
11
9
  export const BASE_SCOPES = [
12
10
  'https://www.googleapis.com/auth/gmail.modify',
@@ -50,18 +48,17 @@ function legacyGlobalProfile() {
50
48
  if (bundle === 'admin') {
51
49
  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
50
  }
53
- if (!(bundle in BUNDLE_CATALOG)) {
51
+ if (!isKnownBundle(bundle)) {
54
52
  const hint = closestBundle(bundle);
55
53
  throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
56
54
  }
57
55
  }
58
56
  return { bundles };
59
57
  }
60
- function profileForAccount(alias) {
58
+ function profileForAccount(alias, set = getAccountSet()) {
61
59
  const legacy = legacyGlobalProfile();
62
60
  if (legacy)
63
61
  return legacy;
64
- const set = getAccountSet();
65
62
  const name = set.configs[alias]?.scopeProfile ?? 'base';
66
63
  // hasOwn: a profile named like an Object.prototype member must never
67
64
  // resolve to the inherited function.
@@ -69,14 +66,13 @@ function profileForAccount(alias) {
69
66
  }
70
67
  /** Union of every account's resolved bundles: a service registers if ANY
71
68
  * account can authorize it; per-account authz happens at call time (BR2). */
72
- export function getOptionalBundles() {
69
+ export function getOptionalBundles(set = getAccountSet()) {
73
70
  const legacy = legacyGlobalProfile();
74
71
  if (legacy)
75
72
  return legacy.bundles.filter(b => b !== 'admin');
76
73
  const union = new Set();
77
- const set = getAccountSet();
78
74
  for (const alias of set.aliases) {
79
- for (const b of profileForAccount(alias).bundles) {
75
+ for (const b of profileForAccount(alias, set).bundles) {
80
76
  if (b !== 'admin')
81
77
  union.add(b);
82
78
  }
@@ -86,12 +82,12 @@ export function getOptionalBundles() {
86
82
  /** Aliases granted ADMIN_SCOPES: per-account admin flag (env
87
83
  * GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
88
84
  * scope profile carrying admin (boolean or "admin" bundle) — equivalent forms. */
89
- export function getAdminAccounts() {
90
- const { aliases, configs } = getAccountSet();
85
+ export function getAdminAccounts(set = getAccountSet()) {
86
+ const { aliases, configs } = set;
91
87
  return aliases.filter((a) => {
92
88
  if (configs[a].admin === true)
93
89
  return true;
94
- const p = profileForAccount(a);
90
+ const p = profileForAccount(a, set);
95
91
  return p.admin === true || p.bundles.includes('admin');
96
92
  });
97
93
  }
@@ -99,15 +95,15 @@ export function getAdminAccounts() {
99
95
  * legacy env) changes its consent set and requires re-running auth. Evaluated
100
96
  * per account: `work` can carry admin + gmail_settings while `personal` is
101
97
  * never asked for them. */
102
- export function resolveScopesForAccount(alias) {
103
- const profile = profileForAccount(alias);
98
+ export function resolveScopesForAccount(alias, set = getAccountSet()) {
99
+ const profile = profileForAccount(alias, set);
104
100
  const scopes = profile.includesBase === false ? [] : [...BASE_SCOPES];
105
101
  for (const bundle of profile.bundles) {
106
102
  if (bundle === 'admin')
107
103
  continue;
108
104
  scopes.push(...BUNDLE_CATALOG[bundle].scopes);
109
105
  }
110
- if (getAdminAccounts().includes(alias)) {
106
+ if (getAdminAccounts(set).includes(alias)) {
111
107
  scopes.push(...ADMIN_SCOPES);
112
108
  }
113
109
  return Array.from(new Set(scopes));
@@ -134,7 +130,11 @@ export async function runAuthFlow(args) {
134
130
  // Auto-provisions on a fresh install (env > keychain > file > generate);
135
131
  // resolves eagerly so a provisioning failure surfaces before the browser opens.
136
132
  resolveMasterKey();
137
- const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
133
+ // Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
134
+ // build the auth URL from the assigned redirect. CLI gets a patient timeout;
135
+ // errors propagate to main()'s fatal handler like every other CLI failure.
136
+ const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
137
+ const oauth2Client = buildConsentClient(loop.redirect);
138
138
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
139
139
  const expectedState = randomBytes(32).toString('hex');
140
140
  const authorizeUrl = oauth2Client.generateAuthUrl({
@@ -149,72 +149,13 @@ export async function runAuthFlow(args) {
149
149
  if (getAdminAccounts().includes(alias)) {
150
150
  console.log(' ⚠ Admin scopes included — this account will be granted Workspace admin access.');
151
151
  }
152
- console.log(`Opening browser for authorization...`);
153
- return new Promise((resolve, reject) => {
154
- const server = http
155
- .createServer(async (req, res) => {
156
- try {
157
- if (req.url && req.url.startsWith('/oauth2callback')) {
158
- const qs = new URL(req.url, 'http://localhost:4242').searchParams;
159
- const error = qs.get('error');
160
- if (error) {
161
- res.writeHead(400, { 'Content-Type': 'text/plain' });
162
- res.end(`Authorization denied: ${error}`);
163
- server.close();
164
- server.closeAllConnections();
165
- reject(new Error(`Authorization denied: ${error}`));
166
- return;
167
- }
168
- const code = qs.get('code');
169
- if (!code) {
170
- res.writeHead(400, { 'Content-Type': 'text/plain' });
171
- res.end('No authorization code received.');
172
- server.close();
173
- server.closeAllConnections();
174
- reject(new Error('No authorization code received'));
175
- return;
176
- }
177
- const returnedState = qs.get('state');
178
- if (returnedState !== expectedState) {
179
- res.writeHead(400, { 'Content-Type': 'text/plain' });
180
- res.end('State mismatch — possible CSRF attempt. Aborting.');
181
- server.close();
182
- server.closeAllConnections();
183
- reject(new Error('OAuth state token mismatch'));
184
- return;
185
- }
186
- const { tokens } = await oauth2Client.getToken(code);
187
- writeToken(alias, tokens);
188
- res.writeHead(200, { 'Content-Type': 'text/html' });
189
- res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
190
- server.close();
191
- server.closeAllConnections();
192
- console.log(`Token saved (encrypted) for ${alias}.`);
193
- console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
194
- resolve();
195
- }
196
- }
197
- catch (e) {
198
- res.writeHead(500, { 'Content-Type': 'text/plain' });
199
- res.end('Internal error during authentication.');
200
- server.close();
201
- server.closeAllConnections();
202
- reject(e);
203
- }
204
- })
205
- // Bind to loopback only — never expose the OAuth callback to the local network.
206
- .listen(4242, '127.0.0.1', () => {
207
- // Always print the URL first: the browser launch is best-effort and
208
- // silently does nothing on headless/SSH sessions.
209
- console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
210
- openUrl(authorizeUrl);
211
- });
212
- server.on('error', (err) => {
213
- if (err.code === 'EADDRINUSE') {
214
- console.error('Port 4242 is already in use. Close the process using it and retry.');
215
- process.exit(1);
216
- }
217
- reject(err);
218
- });
219
- });
152
+ // Always print the URL first: the browser launch is best-effort and
153
+ // silently does nothing on headless/SSH sessions.
154
+ console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
155
+ openUrl(authorizeUrl);
156
+ const tokens = await loop.finish(oauth2Client, expectedState);
157
+ writeToken(alias, tokens);
158
+ console.log(`Token saved (encrypted) for ${alias}.`);
159
+ console.log(TESTING_MODE_WARNING);
160
+ console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
220
161
  }
@@ -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
+ /** BR-4 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,38 @@
1
+ // S1.16: multi-tenant boot gates. The free core never sets these — every
2
+ // helper reduces to today's single-owner behavior when no gates are
3
+ // installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
4
+ // resolve env at import time) so the tenancy module can import it cheaply.
5
+ import { failStartup } from './config-file.js';
6
+ let gates = null;
7
+ export function setMtBootGates(g) {
8
+ gates = g;
9
+ }
10
+ export function mtBootGates() {
11
+ return gates;
12
+ }
13
+ export function isMultiTenantBoot() {
14
+ return gates?.multiTenant === true;
15
+ }
16
+ /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
17
+ * multi-tenant HTTP deploy starts empty by design. */
18
+ export function accountsAssertRequired(wantStdio, multiTenant) {
19
+ return wantStdio || !multiTenant;
20
+ }
21
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
22
+ * the env registry is process-wide, so it would graft one operator's accounts
23
+ * onto every tenant and turn every tenant's first wizard call into a dead
24
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
25
+ * of one clear one. */
26
+ export function assertNoEnvAccountsMode(env = process.env) {
27
+ if (env.GOOGLE_ACCOUNTS?.trim()) {
28
+ 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.');
29
+ }
30
+ }
31
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
32
+ * global scope profile, so on a multi-tenant box one operator's scope choice
33
+ * would silently apply to every tenant's consent. */
34
+ export function assertNoEnvOptionalScopesMode(env = process.env) {
35
+ if (env.GOOGLE_OPTIONAL_SCOPES?.trim()) {
36
+ 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.');
37
+ }
38
+ }
@@ -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,117 @@
1
+ // H4 F6: GET /authorize is unauthenticated, and a client_id on an allowlisted
2
+ // issuer with a path never seen before forces an outbound fetch. So the
3
+ // fetches running at once are capped, one client_id is fetched once however
4
+ // many requests wait on it, and only a document that passed validation is
5
+ // kept. Errors are never cached (CIMD section 5.2), and a negative cache would
6
+ // not help anyway: the attack varies the path, so its key never repeats.
7
+ import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
8
+ import { logSafe } from './trim.js';
9
+ export const CIMD_TTL_MS = 5 * 60_000;
10
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
11
+ export const CIMD_MAX_STALE_MS = 24 * 60 * 60_000;
12
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
13
+ export const CIMD_MAX_IN_FLIGHT = 4;
14
+ export const CIMD_CACHE_MAX = 256;
15
+ const BUSY_LOG_EVERY_MS = 60_000;
16
+ export class CimdClientCache {
17
+ deps;
18
+ entries = new Map();
19
+ // One entry per running fetch, so its size is the number of permits taken.
20
+ inFlight = new Map();
21
+ busyLoggedAt = -Infinity;
22
+ busySuppressed = 0;
23
+ constructor(deps) {
24
+ this.deps = deps;
25
+ }
26
+ get size() {
27
+ return this.entries.size;
28
+ }
29
+ /** `validate` runs once per fetch, in the request that started it, and every
30
+ * request waiting on that fetch gets the same answer. */
31
+ async resolve(clientId, validate) {
32
+ const entry = this.entries.get(clientId);
33
+ const age = entry ? this.deps.now() - entry.at : 0;
34
+ if (entry && age < CIMD_TTL_MS)
35
+ return { doc: entry.doc, fresh: false };
36
+ if (entry && age >= CIMD_MAX_STALE_MS)
37
+ this.entries.delete(clientId);
38
+ const running = this.inFlight.get(clientId);
39
+ if (running) {
40
+ const out = await running;
41
+ return 'doc' in out ? { doc: out.doc, fresh: false } : out;
42
+ }
43
+ if (this.inFlight.size >= CIMD_MAX_IN_FLIGHT) {
44
+ // A flood of fresh paths is exactly when the cap is full, and a client
45
+ // that signed in before must still be able to.
46
+ const stale = this.entries.get(clientId);
47
+ if (stale)
48
+ return { doc: stale.doc, fresh: false };
49
+ this.noteBusy();
50
+ return { busy: true };
51
+ }
52
+ return this.fetchFor(clientId, validate);
53
+ }
54
+ async fetchFor(clientId, validate) {
55
+ // Never rejects: neither core nor a host installs an unhandledRejection
56
+ // handler, and a waiter that stopped listening must not take the process down.
57
+ let settle;
58
+ this.inFlight.set(clientId, new Promise((r) => (settle = r)));
59
+ let out = { error: 'E_CIMD_INVALID' };
60
+ try {
61
+ const doc = await this.deps.fetch(clientId);
62
+ if (validate(doc)) {
63
+ this.store(clientId, doc);
64
+ out = { doc, fresh: true };
65
+ }
66
+ else {
67
+ this.entries.delete(clientId);
68
+ }
69
+ }
70
+ catch (e) {
71
+ out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
72
+ // A document that went bad is never served stale. An issuer that is down
73
+ // or overloaded says nothing about the document, so the last good one
74
+ // stays and answers this fetch's requests too (stale-if-error; the error
75
+ // itself is never stored).
76
+ const stale = this.entries.get(clientId);
77
+ if (!saysNothingAboutDocument(e))
78
+ this.entries.delete(clientId);
79
+ else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
80
+ out = { doc: stale.doc, fresh: false };
81
+ // #13: the detail goes to the log only; the caller gets a generic answer.
82
+ this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
83
+ }
84
+ finally {
85
+ this.inFlight.delete(clientId);
86
+ settle(out);
87
+ }
88
+ return out;
89
+ }
90
+ store(clientId, doc) {
91
+ this.entries.delete(clientId);
92
+ while (this.entries.size >= CIMD_CACHE_MAX) {
93
+ const oldest = this.entries.keys().next().value;
94
+ if (oldest === undefined)
95
+ break;
96
+ this.entries.delete(oldest);
97
+ }
98
+ this.entries.set(clientId, { doc, at: this.deps.now() });
99
+ }
100
+ // Over-cap requests arrive at whatever rate the caller likes.
101
+ noteBusy() {
102
+ const now = this.deps.now();
103
+ if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
104
+ this.busySuppressed++;
105
+ return;
106
+ }
107
+ const suppressed = this.busySuppressed;
108
+ this.busyLoggedAt = now;
109
+ this.busySuppressed = 0;
110
+ this.deps.log(`CIMD busy: in-flight cap reached${suppressed > 0 ? ` (n=${suppressed} suppressed)` : ''}`);
111
+ }
112
+ }
113
+ function saysNothingAboutDocument(e) {
114
+ if (isTransientFetchError(e))
115
+ return true;
116
+ return e instanceof CimdHttpError && (e.status === 408 || e.status === 429 || e.status >= 500);
117
+ }
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,11 @@
1
+ import type { 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 declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
7
+ /** The per-request hooks bound to one registry: argument shapes, unknown-
8
+ * argument screening and the account a validation envelope names. Both
9
+ * transports and every resolved server target build them here, so a mistyped
10
+ * argument behaves the same wherever it arrives. */
11
+ export declare function requestHooksFor(registry: ToolRegistry, ctx: Pick<IdentityContext, 'accounts'>, metrics?: Metrics | null, env?: NodeJS.ProcessEnv): Omit<ServerTarget, 'server'>;
@@ -0,0 +1,109 @@
1
+ // S1.12: registry composition, extracted from index.ts so the tenancy module
2
+ // (and tests) can build registries without touching the CLI entrypoint. This
3
+ // module must stay free of import-time side effects of its own: no argv
4
+ // parsing, no process.exit, no prints (accounts.js module-load resolution is
5
+ // the one inherited, pre-existing exception on the import chain).
6
+ import { GENERATED_SERVICES } from './tools/generated/index.js';
7
+ import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
8
+ import { argNormalizationEnabled } from './arg-normalize.js';
9
+ import { unknownArgMode } from './arg-strict.js';
10
+ import { ToolRegistry } from './registry.js';
11
+ import { registerDiscoverTools } from './discover.js';
12
+ import { registerEscapeTools } from './tools/google-api.js';
13
+ import { accountHealthDepsFor, registerAccountTools } from './tools/accounts-tool.js';
14
+ import { registerDiagnoseTool } from './doctor.js';
15
+ import { registerAccountWizardTools } from './tools/account-wizard.js';
16
+ import { getToolsets, toolsetEnabled } from './toolsets.js';
17
+ import { isOwnerContext } from './identity.js';
18
+ import { resolveScopesForAccount } from './auth.js';
19
+ export function buildRegistry(server, ctx, mode, metrics = null) {
20
+ const policy = ctx.policy;
21
+ // The registry's account view IS the context's: fan-out expansion,
22
+ // selector validation and default-account injection all follow this
23
+ // context, not the process global (identical for the free core's one ctx).
24
+ const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
25
+ // Every handler resolves its client, token reads and scope hints through
26
+ // this context; ctx.accounts is read per call, never snapshotted here, so
27
+ // the owner's live getter stays live.
28
+ const owner = isOwnerContext(ctx);
29
+ const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
30
+ const execDeps = {
31
+ getClientFn: ctx.getClient,
32
+ scopeDeps: {
33
+ readTokenFn: ctx.tokenStore.readToken,
34
+ profileFn: (alias) => resolveScopesForAccount(alias, ctx.accounts),
35
+ },
36
+ };
37
+ const toolsets = getToolsets();
38
+ if (toolsets !== 'all') {
39
+ const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
40
+ for (const requested of toolsets) {
41
+ if (!known.has(requested)) {
42
+ process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
43
+ }
44
+ }
45
+ }
46
+ for (const svc of SERVICES) {
47
+ if (!toolsetEnabled(toolsets, svc.name))
48
+ continue;
49
+ if (svc.enabled && !svc.enabled(ctx.accounts)) {
50
+ if (toolsets !== 'all') {
51
+ const hint = svc.name === 'admin' ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS)' : `add "${svc.name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`;
52
+ process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
53
+ }
54
+ continue;
55
+ }
56
+ svc.register(registry, clientDeps);
57
+ }
58
+ for (const gen of GENERATED_SERVICES) {
59
+ if (!toolsetEnabled(toolsets, gen.name))
60
+ continue;
61
+ const curated = SERVICES.find((s) => s.name === gen.name);
62
+ const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
63
+ if (gate && !gate(ctx.accounts)) {
64
+ if (!curated && toolsets !== 'all') {
65
+ process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
66
+ }
67
+ continue;
68
+ }
69
+ gen.register(registry, execDeps);
70
+ }
71
+ if (registry.services().length === 0) {
72
+ const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
73
+ throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
74
+ `Known services: ${known.join(', ')}. ` +
75
+ `Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
76
+ }
77
+ registerDiscoverTools(registry, policy);
78
+ registerEscapeTools(registry, policy, execDeps);
79
+ registerAccountTools(registry, accountHealthDepsFor(ctx.tokenStore));
80
+ registerDiagnoseTool(registry, ctx);
81
+ // The wizard edits the owner's config.json and token store; any other
82
+ // context has neither.
83
+ if (owner)
84
+ registerAccountWizardTools(registry, server, () => servicesAwaitingRestart(registry));
85
+ return registry;
86
+ }
87
+ /** The per-request hooks bound to one registry: argument shapes, unknown-
88
+ * argument screening and the account a validation envelope names. Both
89
+ * transports and every resolved server target build them here, so a mistyped
90
+ * argument behaves the same wherever it arrives. */
91
+ export function requestHooksFor(registry, ctx, metrics = null, env = process.env) {
92
+ const mode = unknownArgMode(env);
93
+ return {
94
+ argShapeFor: argNormalizationEnabled(env) ? (tool) => registry.argShape(tool) : undefined,
95
+ strictArgs: mode === 'off'
96
+ ? undefined
97
+ : {
98
+ mode,
99
+ declaredFor: (tool) => registry.declaredKeys(tool),
100
+ siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
101
+ unknownTool: (name) => unknownToolMessage(registry, name),
102
+ onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
103
+ },
104
+ validationEnvelope: {
105
+ isKnownTool: (name) => registry.hasTool(name),
106
+ defaultAccount: () => ctx.accounts.defaultAccount,
107
+ },
108
+ };
109
+ }
@@ -1,7 +1,20 @@
1
1
  export declare const CONFIG_VERSION = 1;
2
2
  export declare function configDir(env?: NodeJS.ProcessEnv): string;
3
3
  export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
4
+ export declare function tenantsDir(env?: NodeJS.ProcessEnv): string;
5
+ export declare function tenantConfigFilePath(tenantId: string, env?: NodeJS.ProcessEnv): string;
6
+ export declare function tenantTokenDir(tenantId: string, env?: NodeJS.ProcessEnv): string;
7
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
8
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
9
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
10
+ * tenant provisions first) must never stay group-readable. */
11
+ export declare function ensureTenantDirs(tenantId: string, env?: NodeJS.ProcessEnv): {
12
+ configFile: string;
13
+ tokenDir: string;
14
+ };
4
15
  export declare const ALIAS_RE: RegExp;
16
+ export declare const RESERVED_ALIASES: string[];
17
+ export declare function isReservedAlias(name: string): boolean;
5
18
  export interface ConfigFile {
6
19
  version: number;
7
20
  accounts?: Record<string, {
@@ -17,6 +30,7 @@ export interface ConfigFile {
17
30
  defaultAccount?: string;
18
31
  discovery?: 'lazy' | 'curated' | 'eager';
19
32
  toolsets?: string;
33
+ usageMetrics?: boolean;
20
34
  }
21
35
  export declare function failStartup(slug: string, message: string): never;
22
36
  export declare class ConfigFileError extends Error {
@@ -10,6 +10,39 @@ export function configDir(env = process.env) {
10
10
  export function configFilePath(env = process.env) {
11
11
  return path.join(configDir(env), 'config.json');
12
12
  }
13
+ export function tenantsDir(env = process.env) {
14
+ return path.join(configDir(env), 'tenants');
15
+ }
16
+ // A tenant id becomes a filesystem path segment exactly like an account
17
+ // alias, so it rides the same traversal guard. 'owner' is the single-owner
18
+ // context's subject and client lane, so no tenant may take it.
19
+ function assertTenantId(tenantId) {
20
+ if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
21
+ throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
22
+ }
23
+ }
24
+ export function tenantConfigFilePath(tenantId, env = process.env) {
25
+ assertTenantId(tenantId);
26
+ return path.join(tenantsDir(env), tenantId, 'config.json');
27
+ }
28
+ export function tenantTokenDir(tenantId, env = process.env) {
29
+ assertTenantId(tenantId);
30
+ return path.join(tenantsDir(env), tenantId, 'tokens');
31
+ }
32
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
33
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
34
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
35
+ * tenant provisions first) must never stay group-readable. */
36
+ export function ensureTenantDirs(tenantId, env = process.env) {
37
+ const tokenDir = tenantTokenDir(tenantId, env);
38
+ const levels = [configDir(env), tenantsDir(env), path.join(tenantsDir(env), tenantId), tokenDir];
39
+ for (const dir of levels) {
40
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ if (process.platform !== 'win32')
42
+ fs.chmodSync(dir, 0o700);
43
+ }
44
+ return { configFile: tenantConfigFilePath(tenantId, env), tokenDir };
45
+ }
13
46
  // Alias charset is load-bearing security, not cosmetics: the alias becomes a
14
47
  // tokenDir path segment (encPath), so this guard blocks path traversal.
15
48
  export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
@@ -22,7 +55,10 @@ const accountEntrySchema = z.strictObject({
22
55
  // masterKey, jwtKey, clientId) fails validation, structurally enforcing
23
56
  // secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
24
57
  // of the frozen envelope; their consumers land in later slices.
25
- const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
58
+ export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
59
+ export function isReservedAlias(name) {
60
+ return RESERVED_ALIASES.includes(name);
61
+ }
26
62
  const configSchema = z.strictObject({
27
63
  version: z.number().int(),
28
64
  accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
@@ -36,6 +72,10 @@ const configSchema = z.strictObject({
36
72
  defaultAccount: z.string().optional(),
37
73
  discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
38
74
  toolsets: z.string().optional(),
75
+ // Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
76
+ // a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
77
+ // (strictObject); remove the key first.
78
+ usageMetrics: z.boolean().optional(),
39
79
  });
40
80
  export function failStartup(slug, message) {
41
81
  process.stderr.write(`${slug}: ${message}\n`);