mcp-google-multi 6.0.0-beta.2 → 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 (165) hide show
  1. package/dist/accounts.d.ts +17 -1
  2. package/dist/accounts.js +52 -15
  3. package/dist/api-probe.d.ts +5 -0
  4. package/dist/api-probe.js +20 -15
  5. package/dist/arg-normalize.js +6 -5
  6. package/dist/arg-strict.d.ts +7 -0
  7. package/dist/arg-strict.js +34 -4
  8. package/dist/auth.d.ts +4 -3
  9. package/dist/auth.js +11 -13
  10. package/dist/boot-gates.d.ts +26 -0
  11. package/dist/boot-gates.js +38 -0
  12. package/dist/cimd-cache.d.ts +38 -0
  13. package/dist/cimd-cache.js +117 -0
  14. package/dist/client.d.ts +32 -2
  15. package/dist/client.js +0 -0
  16. package/dist/compose.d.ts +11 -0
  17. package/dist/compose.js +109 -0
  18. package/dist/config-file.d.ts +13 -0
  19. package/dist/config-file.js +37 -1
  20. package/dist/discover.js +3 -1
  21. package/dist/discovery-client.d.ts +3 -0
  22. package/dist/discovery-client.js +25 -2
  23. package/dist/doctor.d.ts +6 -2
  24. package/dist/doctor.js +119 -23
  25. package/dist/executor.d.ts +3 -0
  26. package/dist/executor.js +4 -2
  27. package/dist/fanout.d.ts +9 -1
  28. package/dist/fanout.js +44 -10
  29. package/dist/fs-atomic.d.ts +11 -0
  30. package/dist/fs-atomic.js +219 -28
  31. package/dist/hint-provider.d.ts +129 -0
  32. package/dist/hint-provider.js +64 -0
  33. package/dist/http-transport.d.ts +62 -4
  34. package/dist/http-transport.js +343 -24
  35. package/dist/identity.d.ts +4 -1
  36. package/dist/identity.js +25 -6
  37. package/dist/index.js +66 -138
  38. package/dist/master-key.js +13 -5
  39. package/dist/mcp-token.d.ts +136 -19
  40. package/dist/mcp-token.js +569 -72
  41. package/dist/migrate-config.js +2 -2
  42. package/dist/oauth-as.d.ts +103 -3
  43. package/dist/oauth-as.js +374 -79
  44. package/dist/oauth-consent.d.ts +10 -0
  45. package/dist/oauth-consent.js +16 -0
  46. package/dist/outbound-allowlist.js +5 -2
  47. package/dist/reauth-hint.d.ts +1 -1
  48. package/dist/reauth-hint.js +12 -8
  49. package/dist/registry-transfer.d.ts +5 -0
  50. package/dist/registry-transfer.js +65 -5
  51. package/dist/registry.d.ts +18 -1
  52. package/dist/registry.js +71 -15
  53. package/dist/scope-catalog.d.ts +11 -0
  54. package/dist/scope-catalog.js +23 -6
  55. package/dist/scope-observability.d.ts +8 -6
  56. package/dist/scope-observability.js +29 -13
  57. package/dist/services.d.ts +15 -10
  58. package/dist/services.js +59 -15
  59. package/dist/ssrf-guard.d.ts +23 -9
  60. package/dist/ssrf-guard.js +101 -62
  61. package/dist/tenant-purge.d.ts +26 -0
  62. package/dist/tenant-purge.js +67 -0
  63. package/dist/token-store.d.ts +20 -2
  64. package/dist/token-store.js +0 -0
  65. package/dist/tools/_errors.d.ts +3 -0
  66. package/dist/tools/_errors.js +53 -24
  67. package/dist/tools/_local-files.d.ts +9 -0
  68. package/dist/tools/_local-files.js +18 -0
  69. package/dist/tools/account-wizard.d.ts +10 -1
  70. package/dist/tools/account-wizard.js +74 -17
  71. package/dist/tools/accounts-tool.d.ts +7 -0
  72. package/dist/tools/accounts-tool.js +34 -18
  73. package/dist/tools/admin.d.ts +10 -1
  74. package/dist/tools/admin.js +15 -11
  75. package/dist/tools/analytics.d.ts +10 -1
  76. package/dist/tools/analytics.js +13 -8
  77. package/dist/tools/calendar.d.ts +2 -1
  78. package/dist/tools/calendar.js +18 -14
  79. package/dist/tools/chat.d.ts +10 -1
  80. package/dist/tools/chat.js +13 -8
  81. package/dist/tools/contacts.d.ts +2 -1
  82. package/dist/tools/contacts.js +17 -13
  83. package/dist/tools/docs.d.ts +2 -1
  84. package/dist/tools/docs.js +34 -30
  85. package/dist/tools/drive.d.ts +2 -1
  86. package/dist/tools/drive.js +65 -50
  87. package/dist/tools/forms.d.ts +10 -1
  88. package/dist/tools/forms.js +19 -12
  89. package/dist/tools/generated/_shared.d.ts +2 -1
  90. package/dist/tools/generated/_shared.js +7 -3
  91. package/dist/tools/generated/admin.d.ts +2 -1
  92. package/dist/tools/generated/admin.js +265 -265
  93. package/dist/tools/generated/analytics.d.ts +2 -1
  94. package/dist/tools/generated/analytics.js +125 -125
  95. package/dist/tools/generated/appsmarket.d.ts +2 -1
  96. package/dist/tools/generated/appsmarket.js +5 -5
  97. package/dist/tools/generated/calendar.d.ts +2 -1
  98. package/dist/tools/generated/calendar.js +55 -55
  99. package/dist/tools/generated/chat.d.ts +2 -1
  100. package/dist/tools/generated/chat.js +95 -95
  101. package/dist/tools/generated/classroom.d.ts +2 -1
  102. package/dist/tools/generated/classroom.js +209 -209
  103. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  104. package/dist/tools/generated/cloudidentity.js +133 -133
  105. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  106. package/dist/tools/generated/cloudsearch.js +99 -99
  107. package/dist/tools/generated/contacts.d.ts +2 -1
  108. package/dist/tools/generated/contacts.js +29 -29
  109. package/dist/tools/generated/drive.d.ts +2 -1
  110. package/dist/tools/generated/drive.js +71 -71
  111. package/dist/tools/generated/driveactivity.d.ts +2 -1
  112. package/dist/tools/generated/driveactivity.js +3 -3
  113. package/dist/tools/generated/drivelabels.d.ts +2 -1
  114. package/dist/tools/generated/drivelabels.js +53 -53
  115. package/dist/tools/generated/forms.d.ts +2 -1
  116. package/dist/tools/generated/forms.js +7 -7
  117. package/dist/tools/generated/gmail.d.ts +2 -1
  118. package/dist/tools/generated/gmail.js +117 -117
  119. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  120. package/dist/tools/generated/groupsmigration.js +3 -3
  121. package/dist/tools/generated/groupssettings.d.ts +2 -1
  122. package/dist/tools/generated/groupssettings.js +7 -7
  123. package/dist/tools/generated/index.d.ts +2 -1
  124. package/dist/tools/generated/keep.d.ts +2 -1
  125. package/dist/tools/generated/keep.js +15 -15
  126. package/dist/tools/generated/licensing.d.ts +2 -1
  127. package/dist/tools/generated/licensing.js +15 -15
  128. package/dist/tools/generated/meet.d.ts +2 -1
  129. package/dist/tools/generated/meet.js +27 -27
  130. package/dist/tools/generated/postmaster.d.ts +2 -1
  131. package/dist/tools/generated/postmaster.js +9 -9
  132. package/dist/tools/generated/reseller.d.ts +2 -1
  133. package/dist/tools/generated/reseller.js +35 -35
  134. package/dist/tools/generated/script.d.ts +2 -1
  135. package/dist/tools/generated/script.js +33 -33
  136. package/dist/tools/generated/searchconsole.d.ts +2 -1
  137. package/dist/tools/generated/searchconsole.js +3 -3
  138. package/dist/tools/generated/sheets.d.ts +2 -1
  139. package/dist/tools/generated/sheets.js +15 -15
  140. package/dist/tools/generated/tasks.d.ts +2 -1
  141. package/dist/tools/generated/tasks.js +5 -5
  142. package/dist/tools/generated/vault.d.ts +2 -1
  143. package/dist/tools/generated/vault.js +67 -67
  144. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  145. package/dist/tools/generated/workspaceevents.js +31 -31
  146. package/dist/tools/gmail.d.ts +2 -1
  147. package/dist/tools/gmail.js +45 -33
  148. package/dist/tools/google-api.d.ts +2 -3
  149. package/dist/tools/google-api.js +30 -16
  150. package/dist/tools/meet.d.ts +10 -1
  151. package/dist/tools/meet.js +13 -9
  152. package/dist/tools/searchconsole.d.ts +2 -1
  153. package/dist/tools/searchconsole.js +17 -13
  154. package/dist/tools/sheets.d.ts +2 -1
  155. package/dist/tools/sheets.js +36 -32
  156. package/dist/tools/slides.d.ts +10 -1
  157. package/dist/tools/slides.js +17 -10
  158. package/dist/tools/tasks.d.ts +2 -1
  159. package/dist/tools/tasks.js +19 -15
  160. package/dist/trim.d.ts +5 -0
  161. package/dist/trim.js +20 -0
  162. package/dist/usage-metrics.d.ts +23 -0
  163. package/dist/usage-metrics.js +65 -47
  164. package/dist/write-control.js +10 -2
  165. package/package.json +27 -1
package/dist/index.js CHANGED
@@ -1,97 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  // First import: triggers accounts.js module load (env files + registry) before
3
3
  // anything else. Named to also pull the server-only empty-registry guard (BR-4).
4
- import { assertServerAccountsConfigured, getAccountSet } from './accounts.js';
4
+ import { assertServerAccountsConfigured } from './accounts.js';
5
+ import { accountsAssertRequired, assertNoEnvAccountsMode, assertNoEnvOptionalScopesMode, isMultiTenantBoot, mtBootGates } from './boot-gates.js';
5
6
  import { readFileSync } from 'node:fs';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import path from 'node:path';
8
9
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
9
10
  import { McpServer } from "@modelcontextprotocol/server";
10
- import { GENERATED_SERVICES } from './tools/generated/index.js';
11
- import { GENERATED_GATES, SERVICES, unknownToolMessage } from './services.js';
12
- import { ToolRegistry, resolveDiscoveryMode } from './registry.js';
13
- import { registerDiscoverTools } from './discover.js';
14
- import { registerEscapeTools } from './tools/google-api.js';
15
- import { registerAccountTools } from './tools/accounts-tool.js';
16
- import { registerDiagnoseTool } from './doctor.js';
17
- import { registerAccountWizardTools } from './tools/account-wizard.js';
18
- import { getToolsets, toolsetEnabled } from './toolsets.js';
11
+ import { resolveDiscoveryMode } from './registry.js';
19
12
  import { isAllowed, describePolicy } from './write-control.js';
20
13
  import { buildIdentityContext } from './identity.js';
14
+ import { buildRegistry, requestHooksFor } from './compose.js';
21
15
  import { registerSetupPrompt } from './setup-prompt.js';
22
16
  import { applyNetTuning } from './net-tuning.js';
23
- import { argNormalizationEnabled, withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
24
- import { unknownArgMode } from './arg-strict.js';
17
+ import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
25
18
  import { envValueSource } from './env-load.js';
26
19
  import { loadConfigFile } from './config-file.js';
27
20
  import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
28
21
  applyNetTuning();
29
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
23
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
31
- /** Unknown-argument screening options, or undefined when the feature is off.
32
- * Shared by both transports so a mistyped argument behaves identically. */
33
- function strictArgOptions(registry, metrics) {
34
- const mode = unknownArgMode();
35
- if (mode === 'off')
36
- return undefined;
37
- return {
38
- mode,
39
- declaredFor: (tool) => registry.declaredKeys(tool),
40
- siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
41
- unknownTool: (name) => unknownToolMessage(registry, name),
42
- onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
43
- };
44
- }
45
- function buildRegistry(server, ctx, mode, metrics = null) {
46
- const policy = ctx.policy;
47
- const registry = new ToolRegistry(server, policy, mode, metrics);
48
- const toolsets = getToolsets();
49
- if (toolsets !== 'all') {
50
- const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
51
- for (const requested of toolsets) {
52
- if (!known.has(requested)) {
53
- process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
54
- }
55
- }
56
- }
57
- for (const svc of SERVICES) {
58
- if (!toolsetEnabled(toolsets, svc.name))
59
- continue;
60
- if (svc.enabled && !svc.enabled()) {
61
- if (toolsets !== 'all') {
62
- 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)`;
63
- process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
64
- }
65
- continue;
66
- }
67
- svc.register(registry);
68
- }
69
- for (const gen of GENERATED_SERVICES) {
70
- if (!toolsetEnabled(toolsets, gen.name))
71
- continue;
72
- const curated = SERVICES.find((s) => s.name === gen.name);
73
- const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
74
- if (gate && !gate()) {
75
- if (!curated && toolsets !== 'all') {
76
- process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
77
- }
78
- continue;
79
- }
80
- gen.register(registry);
81
- }
82
- if (registry.services().length === 0) {
83
- const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
84
- throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
85
- `Known services: ${known.join(', ')}. ` +
86
- `Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
87
- }
88
- registerDiscoverTools(registry, policy);
89
- registerEscapeTools(registry, policy);
90
- registerAccountTools(registry);
91
- registerDiagnoseTool(registry);
92
- registerAccountWizardTools(registry, server);
93
- return registry;
94
- }
95
24
  async function main() {
96
25
  if (process.argv.includes('auth')) {
97
26
  const { runAuthFlow } = await import('./auth.js');
@@ -198,14 +127,8 @@ async function main() {
198
127
  }
199
128
  return;
200
129
  }
201
- // BR-4: the SERVER never boots with an empty registry. The bootstrap and
202
- // diagnostic CLIs above already returned; a fresh user configures accounts
203
- // (env / migrate-config / account import / auth) before the server runs.
204
- assertServerAccountsConfigured();
205
- // Resolve (or provision) the master key BEFORE serving: the hard guard is
206
- // specced "fatal at startup", never mid-dispatch.
207
- const { resolveMasterKey } = await import('./master-key.js');
208
- resolveMasterKey();
130
+ // Transport derivation FIRST: the accounts gate below is transport-aware
131
+ // (a multi-tenant HTTP deploy legitimately boots with zero accounts).
209
132
  const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
210
133
  let httpCfg;
211
134
  try {
@@ -217,6 +140,21 @@ async function main() {
217
140
  }
218
141
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
219
142
  const wantHttp = transportIncludesHttp(httpCfg.transport);
143
+ // BR-4: the SERVER never boots with an empty registry — stdio always, HTTP
144
+ // when single-owner. The bootstrap and diagnostic CLIs above already
145
+ // returned. A multi-tenant boot instead refuses the two process-wide env
146
+ // vectors that would leak one operator's accounts/scopes into every tenant.
147
+ const mt = isMultiTenantBoot();
148
+ if (accountsAssertRequired(wantStdio, mt))
149
+ assertServerAccountsConfigured();
150
+ if (mt) {
151
+ assertNoEnvAccountsMode();
152
+ assertNoEnvOptionalScopesMode();
153
+ }
154
+ // Resolve (or provision) the master key BEFORE serving: the hard guard is
155
+ // specced "fatal at startup", never mid-dispatch.
156
+ const { resolveMasterKey } = await import('./master-key.js');
157
+ resolveMasterKey();
220
158
  // Local usage metrics: fail-closed resolve, self-announcing source, null
221
159
  // when off (no wrapper, no dir, nothing initializes). One instance per
222
160
  // transport so `boots` keys stay honest under `both`.
@@ -254,40 +192,47 @@ async function main() {
254
192
  if (wantStdio) {
255
193
  const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
256
194
  const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
257
- const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
195
+ const stdioCtx = buildIdentityContext(process.env, { transport: 'stdio' });
196
+ const registry = buildRegistry(server, stdioCtx, undefined, stdioMetrics);
258
197
  registry.installListHandler();
259
198
  registerSetupPrompt(server);
199
+ const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
260
200
  // Outbound runs outermost-first, so the composition is deliberate: the
261
201
  // envelope rewrite is INNERMOST (last to touch the frame), the tap sits
262
202
  // above it (classifying the ORIGINAL validation prose), arg normalization
263
203
  // outermost. The tap only counts outbound JSON-RPC error frames (no
264
204
  // handler ran) plus id->tool names.
265
- let transport = withValidationEnvelope(new StdioServerTransport(), {
266
- isKnownTool: (n) => registry.hasTool(n),
267
- defaultAccount: () => getAccountSet().defaultAccount,
268
- });
205
+ let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
269
206
  if (stdioMetrics) {
270
207
  const { tapUsageMetrics } = await import('./metrics-tap.js');
271
208
  transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
272
209
  }
273
- const strictStdio = strictArgOptions(registry, stdioMetrics);
274
- await server.connect(argNormalizationEnabled() || strictStdio
210
+ await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
275
211
  ? withArgNormalization(transport,
276
212
  // Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
277
213
  // shape unconditionally made stdio rename keys while
278
214
  // GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
279
215
  // and failed on HTTP once screening rejects.
280
- argNormalizationEnabled() ? (n) => registry.argShape(n) : () => undefined, undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, strictStdio)
216
+ stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
281
217
  : transport);
282
218
  }
283
219
  if (wantHttp) {
284
220
  const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
285
- // BR3 / C13: the owner allowlist is the entire multi-tenant collapse; refuse
286
- // to open an ungated HTTP endpoint.
287
221
  const owners = parseOwnerEmails(process.env);
288
- if (owners.length === 0) {
289
- process.stderr.write('E_OWNER_EMAILS_REQUIRED: MCP_TRANSPORT includes http but MCP_OWNER_EMAILS is empty. Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.\n');
290
- process.exit(1);
222
+ const gates = mtBootGates();
223
+ if (gates?.multiTenant) {
224
+ // Same fail-fast SHAPE, different condition: an ungated HTTP endpoint
225
+ // must never boot; under tenancy the gate is "a valid provisioning
226
+ // mechanism exists", not a flat owner allowlist.
227
+ gates.assertProvisioningGate();
228
+ }
229
+ else {
230
+ // BR3 / C13: the owner allowlist is the entire multi-tenant collapse;
231
+ // refuse to open an ungated HTTP endpoint.
232
+ if (owners.length === 0) {
233
+ process.stderr.write('E_OWNER_EMAILS_REQUIRED: MCP_TRANSPORT includes http but MCP_OWNER_EMAILS is empty. Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.\n');
234
+ process.exit(1);
235
+ }
291
236
  }
292
237
  // BR7: stateless HTTP cannot push tools/list_changed, so it forces curated.
293
238
  const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
@@ -296,33 +241,22 @@ async function main() {
296
241
  }
297
242
  const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
298
243
  const httpMetrics = initMetricsFor('http', 'curated');
299
- const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
244
+ const httpCtx = buildIdentityContext(process.env, { transport: 'http' });
245
+ const registry = buildRegistry(httpServer, httpCtx, 'curated', httpMetrics);
300
246
  registry.installListHandler();
301
247
  registerSetupPrompt(httpServer);
302
248
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
303
- const { buildAuthServer } = await import('./oauth-as.js');
249
+ const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
304
250
  const { jwtSecretFrom } = await import('./mcp-token.js');
305
251
  const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
306
252
  const { OAuth2Client } = await import('googleapis-common');
307
- const { writeToken } = await import('./token-store.js');
253
+ const { readToken, writeToken } = await import('./token-store.js');
308
254
  const { resolveScopesForAccount } = await import('./auth.js');
255
+ const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
256
+ const { scopeGrantDiff, setWizardHttpConsent } = await import('./tools/account-wizard.js');
309
257
  const { configDir } = await import('./config-file.js');
310
258
  const { getAccountSet } = await import('./accounts.js');
311
259
  const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
312
- // Only trust the id_token email when Google marks it verified (#8).
313
- const verifiedEmailFromIdToken = (idToken) => {
314
- if (!idToken)
315
- return undefined;
316
- try {
317
- const [, payload] = idToken.split('.');
318
- const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
319
- const verified = claims.email_verified === true || claims.email_verified === 'true';
320
- return verified ? claims.email : undefined;
321
- }
322
- catch {
323
- return undefined;
324
- }
325
- };
326
260
  const authServer = buildAuthServer({
327
261
  base: httpCfg.publicUrl,
328
262
  resourceUri: httpCfg.resourceUri,
@@ -333,20 +267,7 @@ async function main() {
333
267
  masterKey: resolveMasterKeyForDispatch().key,
334
268
  refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
335
269
  }, {
336
- buildGoogleAuthUrl: ({ flow, alias, state }) => {
337
- if (flow === 'alias_reauth' && alias) {
338
- const cfg = getAccountSet().configs[alias];
339
- // openid+email so /callback can bind the returned identity to the alias.
340
- return googleClient().generateAuthUrl({
341
- access_type: 'offline',
342
- prompt: 'consent',
343
- scope: [...new Set([...resolveScopesForAccount(alias), 'openid', 'email'])],
344
- login_hint: cfg?.email,
345
- state,
346
- });
347
- }
348
- return googleClient().generateAuthUrl({ scope: ['openid', 'email'], prompt: 'select_account', state });
349
- },
270
+ buildGoogleAuthUrl: ({ flow, alias, state }) => googleClient().generateAuthUrl(ownerAuthUrlOptions({ flow, alias, state }, (a) => resolveScopesForAccount(a))),
350
271
  exchangeCode: async (code, flow) => {
351
272
  const { tokens } = await googleClient().getToken(code);
352
273
  const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
@@ -355,13 +276,25 @@ async function main() {
355
276
  return { tokens: flow === 'owner_gate' ? {} : tokens, email };
356
277
  },
357
278
  writeToken: (alias, tokens) => writeToken(alias, tokens),
279
+ missingScopes: (alias, granted) => scopeGrantDiff(resolveScopesForAccount(alias), granted),
280
+ hasToken: (alias) => {
281
+ try {
282
+ return readToken(alias) != null;
283
+ }
284
+ catch {
285
+ return false; // undecryptable: nothing usable to keep
286
+ }
287
+ },
358
288
  aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
359
289
  log: (l) => process.stderr.write(`[as] ${l}\n`),
360
290
  });
361
- // BV-1: over HTTP, a dead/missing per-account token surfaces a clickable
362
- // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
363
- const { setHttpReauthBase } = await import('./reauth-hint.js');
364
- setHttpReauthBase(httpCfg.publicUrl);
291
+ // BV-1: over HTTP, a dead/missing per-account token surfaces a clickable,
292
+ // signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
293
+ const { setHttpReauthLink } = await import('./reauth-hint.js');
294
+ setHttpReauthLink((alias) => authServer.reauthLink(alias));
295
+ // Wizard consent over HTTP: hand out the same signed link instead of
296
+ // binding a loopback listener.
297
+ setWizardHttpConsent({ mintConsentUrl: (alias) => authServer.reauthLink(alias) });
365
298
  let httpTap;
366
299
  if (httpMetrics) {
367
300
  const { tapUsageMetrics } = await import('./metrics-tap.js');
@@ -375,14 +308,9 @@ async function main() {
375
308
  authenticate: authServer.authenticate,
376
309
  routes: authServer.routes,
377
310
  log: (l) => process.stderr.write(`[http] ${l}\n`),
378
- argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
311
+ ...requestHooksFor(registry, httpCtx, httpMetrics),
379
312
  metricsTap: httpTap,
380
- validationEnvelope: {
381
- isKnownTool: (n) => registry.hasTool(n),
382
- defaultAccount: () => getAccountSet().defaultAccount,
383
- },
384
313
  onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
385
- strictArgs: strictArgOptions(registry, httpMetrics),
386
314
  });
387
315
  await host.start();
388
316
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
@@ -38,17 +38,25 @@ let keychainFactory = systemKeychain;
38
38
  export function __setKeychainFactoryForTest(fn) {
39
39
  keychainFactory = fn ?? systemKeychain;
40
40
  }
41
- // Glob the token dir instead of iterating registered aliases: an orphan .enc
42
- // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
43
- // one client) must still stop a fresh key from being minted.
44
- function anyTokenFileExists() {
41
+ function anyEncFileUnder(dir) {
45
42
  try {
46
- return fs.readdirSync(getTokenDir()).some((f) => f.endsWith('.enc'));
43
+ return fs.readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith('.enc'));
47
44
  }
48
45
  catch {
49
46
  return false;
50
47
  }
51
48
  }
49
+ // Glob the token dir instead of iterating registered aliases: an orphan .enc
50
+ // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
51
+ // one client) must still stop a fresh key from being minted. TOKEN_STORE_PATH
52
+ // may relocate the token dir outside configDir(), so scan it directly; the
53
+ // recursive configDir() walk then covers BOTH the tenant-namespaced token root
54
+ // (tenants/<id>) AND any top-level master-key-encrypted registry beside it
55
+ // (e.g. a downstream tenant map / invite store). Minting a fresh MASTER_KEY
56
+ // over any existing .enc bricks it irreversibly, so the scan must see them all.
57
+ function anyTokenFileExists() {
58
+ return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
59
+ }
52
60
  function readKeyFile(filePath) {
53
61
  try {
54
62
  const raw = fs.readFileSync(filePath, 'utf8').trim();
@@ -1,7 +1,7 @@
1
1
  export declare const ACCESS_TTL_DEFAULT = 600;
2
2
  export declare const STATE_TTL_DEFAULT = 600;
3
3
  export declare const CODE_TTL_DEFAULT = 60;
4
- export declare const REPLAY_CAP_DEFAULT = 10000;
4
+ export declare const REPLAY_CAP_DEFAULT = 100000;
5
5
  /** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
6
6
  export declare function jwtSecretFrom(jwtKey: string): Uint8Array;
7
7
  export interface AccessTokenParams {
@@ -9,6 +9,7 @@ export interface AccessTokenParams {
9
9
  secret: Uint8Array;
10
10
  ttlSec?: number;
11
11
  iat: number;
12
+ sub: string;
12
13
  }
13
14
  export declare function signAccessToken(p: AccessTokenParams): Promise<string>;
14
15
  export interface AccessClaims {
@@ -19,63 +20,179 @@ export interface AccessClaims {
19
20
  /** Verify an access token for `/mcp`. Throws on bad sig / aud / iss / exp. */
20
21
  export declare function verifyAccessToken(token: string, base: string, secret: Uint8Array): Promise<AccessClaims>;
21
22
  export interface StatePayload {
22
- flow: 'owner_gate' | 'alias_reauth';
23
+ flow: 'owner_gate' | 'alias_reauth' | 'alias_add';
23
24
  client_id: string;
24
25
  redirect_uri: string;
25
26
  code_challenge: string;
26
27
  client_state?: string;
27
28
  resource: string;
28
29
  alias?: string;
30
+ /** alias_add only: the tenant the new alias binds under. Signed server-side
31
+ * at mint time — never caller-supplied at /authorize or /callback. */
32
+ tenantId?: string;
33
+ /** alias_add only: scope bundles chosen when the link was minted. */
34
+ bundles?: string[];
35
+ /** alias_add only: an opaque value the minting caller chose, signed like
36
+ * tenantId and handed back to its binder (e.g. a server-side link record). */
37
+ nonce?: string;
38
+ /** owner_gate only: base64url sha256 of the browser-binding cookie the
39
+ * Google redirect set; /callback refuses a browser that lacks it. */
40
+ bind?: string;
29
41
  }
30
42
  export interface CodePayload {
31
43
  redirect_uri: string;
32
44
  code_challenge: string;
33
45
  resource: string;
34
- sub: 'owner';
46
+ sub: string;
35
47
  }
36
48
  export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
37
- export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
49
+ export declare function verifyState(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
38
50
  jti: string;
51
+ exp: number;
39
52
  }>;
53
+ /** A re-auth link is handed out in a tool error, so it has to survive until
54
+ * the user clicks it; an hour, not the 10-minute state TTL. */
55
+ export declare const REAUTH_LINK_TTL_SEC = 3600;
56
+ /** Query string of a server-issued alias_reauth link: the alias, an expiry and
57
+ * an HMAC over both. Synchronous, so the error hints that carry it stay so. */
58
+ export declare function signReauthLink(base: string, secret: Uint8Array, alias: string, nowSec: number): string;
59
+ /** The alias a link was issued for, or null when it is missing, forged or expired. */
60
+ export declare function verifyReauthLink(base: string, secret: Uint8Array, params: {
61
+ alias: string;
62
+ exp: string;
63
+ sig: string;
64
+ }, nowSec: number): string | null;
40
65
  /** A pending-authorization artifact for the DCR consent interstitial (same
41
66
  * shape as `state`, distinct purpose so the two can't be confused). */
42
67
  export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
43
- export declare function verifyPending(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
68
+ export declare function verifyPending(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
44
69
  jti: string;
70
+ exp: number;
45
71
  }>;
46
72
  export declare function signAuthzCode(payload: CodePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
47
- export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array): Promise<CodePayload & {
73
+ export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<CodePayload & {
48
74
  jti: string;
75
+ exp: number;
49
76
  }>;
77
+ /** `full`: there is no room without forgetting a jti that is still live. */
78
+ export type ReplaySpend = 'ok' | 'replay' | 'full';
50
79
  export declare class ReplayGuard {
51
80
  private readonly cap;
52
81
  private readonly seen;
82
+ private earliest;
83
+ private sweptAt;
53
84
  constructor(cap?: number);
54
- /** Record a jti as spent. Returns false if it was already spent (replay). */
85
+ /** Hold a slot for an artifact about to be handed out, so its spend can
86
+ * never meet a full guard. The flood then lands where the artifact is
87
+ * minted, and refusing there loses nothing. */
88
+ reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
89
+ /** Record a jti as spent. A full guard refuses instead of evicting a live
90
+ * jti: a flood of spends would otherwise make a spent artifact usable again,
91
+ * such as a callback the browser binding refused (H4 F1). `artifactExpMs`
92
+ * keeps the record alive as long as the artifact itself, even when the clock
93
+ * stepped back between its signing and this spend. */
94
+ spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
95
+ /** Undo a spend whose next step could not go ahead. The slot stays held, so
96
+ * nothing is forgotten, and the artifact can be spent once more. */
97
+ unspend(jti: string): void;
98
+ /** `spend` as a boolean: false for a replay and for a full guard alike. */
55
99
  consume(jti: string, ttlMs: number, nowMs: number): boolean;
56
100
  get size(): number;
57
- private evictExpired;
101
+ private record;
102
+ private room;
58
103
  }
104
+ /** An earlier release's record, keyed by its live token. */
59
105
  export interface RefreshRecord {
60
- sub: 'owner';
106
+ sub: string;
61
107
  issuedAt: number;
62
108
  family: string;
63
109
  }
64
110
  /**
65
- * Persisted (encrypted under MASTER_KEY) opaque refresh-token store. Rotates on
66
- * every use (a stolen token is usable at most once), and detects REUSE of a
67
- * rotated-away token as theft: the whole token family is revoked (#7 / C14).
68
- * All read-modify-write goes through a file lock so concurrent stdio+HTTP
69
- * processes can't lost-update or double-spend (#12).
111
+ * The family tag of a refresh token this `masterKey`'s store minted, or null
112
+ * for anything else. Derives the key once; the returned function does no I/O,
113
+ * so a host can key a pre-auth rate limit on it.
114
+ */
115
+ export declare function refreshFamilyTagger(masterKey: string): (token: string) => string | null;
116
+ /** Boot check for a refresh store file: returns when it is absent or this
117
+ * release can read it; throws when it does not decrypt under `masterKey`
118
+ * (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
119
+ * (E_REFRESH_STORE_FORMAT). */
120
+ export declare function assertRefreshStoreReadable(file: string, masterKey: string): void;
121
+ export interface RefreshStoreOptions {
122
+ /** Absolute family lifetime in seconds, counted from the sign-in. Unset: unlimited. */
123
+ maxAgeSec?: number;
124
+ /** Idle lifetime in seconds, counted from the last issue or rotation. Unset: unlimited. */
125
+ idleSec?: number;
126
+ /** Max live refresh-token families per subject (both formats). Unset:
127
+ * unlimited (default; the single-owner server passes none). When issuing a
128
+ * new family would put a subject over this, that subject's own
129
+ * least-recently-used family is dropped (by its last issue or rotation
130
+ * time) until it fits; a dropped family behaves exactly like a revoked
131
+ * one. Only the subject signing in ever triggers it, and only its own
132
+ * families are ever touched. */
133
+ maxFamiliesPerSub?: number;
134
+ /** Server-side log for a revoked, dropped or expired family. Never given a token or a subject. */
135
+ log?: (line: string) => void;
136
+ }
137
+ /**
138
+ * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
139
+ * use, and a token carries its family and generation under a MAC, so
140
+ * presenting any generation the family has rotated past is REUSE: the family
141
+ * is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
142
+ * record per family, whatever its rotation count. Every read-modify-write goes
143
+ * through a file lock so concurrent stdio+HTTP processes can't lost-update or
144
+ * double-spend (#12). See docs/internals.md.
70
145
  */
71
146
  export declare class RefreshStore {
72
147
  private readonly path;
73
148
  private readonly masterKey;
74
- constructor(path: string, masterKey: string);
149
+ private readonly log;
150
+ private readonly maxAgeMs;
151
+ private readonly idleMs;
152
+ private readonly maxFamiliesPerSub;
153
+ private macKey;
154
+ private legacyEmpty;
155
+ constructor(path: string, masterKey: string, options?: RefreshStoreOptions);
156
+ private get timed();
157
+ private live;
158
+ /** Drop expired sessions in both formats; the count of those dropped. */
159
+ private prune;
160
+ /** A session written while the clock ran ahead would otherwise outlive its
161
+ * limit by the size of the jump. */
162
+ private clamp;
163
+ private key;
164
+ /** With `nowMs` (every issue and rotation), also clamps and prunes. */
75
165
  private load;
76
166
  private save;
77
- issue(nowMs: number, family?: string): string;
78
- /** Rotate a presented refresh token; null if unknown OR if the presented
79
- * token was already rotated away (reuse => the family is revoked). */
80
- rotate(oldToken: string, nowMs: number): string | null;
167
+ private emit;
168
+ /** Evict `sub`'s own least-recently-used sessions (both formats) so at
169
+ * most `limit` of its live sessions remain; dropping one here is the same
170
+ * as deleting it from `data.families` / `data.active` on a revoke, so it
171
+ * can never be presented again (reuse detection for every OTHER family is
172
+ * untouched). Only `sub`'s own entries are ever inspected.
173
+ *
174
+ * `keepKey` is the family just issued: it is excluded from the ranking so
175
+ * a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
176
+ * racing ahead of it under the lock, can never make the brand-new family
177
+ * look like the least recently used one and drop itself. */
178
+ private enforceFamilyCap;
179
+ private newFamilyId;
180
+ issue(nowMs: number, sub: string): string;
181
+ /** Rotate a presented refresh token; null if it is unknown, malformed, or
182
+ * rotated away (reuse => the family is revoked). The record's sub is copied
183
+ * forward and returned so the caller can mint the matching access token
184
+ * without trusting anything client-supplied.
185
+ *
186
+ * `accept` (optional) is called inside the store lock, before any mutation,
187
+ * with the record's sub. false: every session of that sub is dropped and
188
+ * null returned. A throw: nothing is written and the error propagates, so
189
+ * the presented token stays valid. Absent: unchanged. */
190
+ rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean): {
191
+ token: string;
192
+ sub: string;
193
+ } | null;
194
+ /** Drop every session minted under `sub`, in both formats (linear scan under
195
+ * the store lock); the save then drops the earlier-release spent entries of
196
+ * the families that died with them. Returns the number of sessions dropped. */
197
+ purgeTenant(sub: string): number;
81
198
  }