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
package/dist/index.js CHANGED
@@ -1,97 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  // First import: triggers accounts.js module load (env files + registry) before
3
- // anything else. Named to also pull the server-only empty-registry guard (BR-4).
4
- import { assertServerAccountsConfigured, getAccountSet } from './accounts.js';
3
+ // anything else. Named to also pull the server-only empty-registry guard.
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
- 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';
10
+ import { resolveDiscoveryMode } from './registry.js';
19
11
  import { isAllowed, describePolicy } from './write-control.js';
20
12
  import { buildIdentityContext } from './identity.js';
13
+ import { buildRegistry, createMcpServer, requestHooksFor } from './compose.js';
21
14
  import { registerSetupPrompt } from './setup-prompt.js';
22
15
  import { applyNetTuning } from './net-tuning.js';
23
- import { argNormalizationEnabled, withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
24
- import { unknownArgMode } from './arg-strict.js';
16
+ import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
25
17
  import { envValueSource } from './env-load.js';
26
18
  import { loadConfigFile } from './config-file.js';
27
19
  import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
28
20
  applyNetTuning();
29
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
22
  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
23
  async function main() {
96
24
  if (process.argv.includes('auth')) {
97
25
  const { runAuthFlow } = await import('./auth.js');
@@ -152,7 +80,7 @@ async function main() {
152
80
  if (process.argv.includes('config') && process.argv.includes('check')) {
153
81
  const ctx = buildIdentityContext();
154
82
  const policy = ctx.policy;
155
- const registry = buildRegistry(new McpServer({ name: 'mcp-google-multi', version: pkg.version }), ctx);
83
+ const registry = buildRegistry(createMcpServer(pkg.version), ctx);
156
84
  const cud = registry.tools.filter((t) => t.cud !== 'read');
157
85
  const disabled = cud.filter((t) => !isAllowed(t, policy));
158
86
  const counts = registry.visibleCount();
@@ -198,14 +126,8 @@ async function main() {
198
126
  }
199
127
  return;
200
128
  }
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();
129
+ // Transport derivation FIRST: the accounts gate below is transport-aware
130
+ // (a multi-tenant HTTP deploy legitimately boots with zero accounts).
209
131
  const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
210
132
  let httpCfg;
211
133
  try {
@@ -217,9 +139,20 @@ async function main() {
217
139
  }
218
140
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
219
141
  const wantHttp = transportIncludesHttp(httpCfg.transport);
220
- // Local usage metrics: fail-closed resolve, self-announcing source, null
221
- // when off (no wrapper, no dir, nothing initializes). One instance per
222
- // transport so `boots` keys stay honest under `both`.
142
+ // The SERVER never boots with an empty registry (stdio always, HTTP when
143
+ // single-owner); a multi-tenant boot instead refuses the two process-wide env vectors.
144
+ const mt = isMultiTenantBoot();
145
+ if (accountsAssertRequired(wantStdio, mt))
146
+ assertServerAccountsConfigured();
147
+ if (mt) {
148
+ assertNoEnvAccountsMode();
149
+ assertNoEnvOptionalScopesMode();
150
+ }
151
+ // Resolve (or provision) the master key BEFORE serving: the hard guard is
152
+ // specced "fatal at startup", never mid-dispatch.
153
+ const { resolveMasterKey } = await import('./master-key.js');
154
+ resolveMasterKey();
155
+ // One Metrics instance per transport so `boots` keys stay honest under `both`.
223
156
  const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
224
157
  const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
225
158
  if (metricsState.warning)
@@ -249,80 +182,69 @@ async function main() {
249
182
  });
250
183
  }
251
184
  }
252
- // Build one McpServer + registry per transport at boot (P1 / BV gap #4:
253
- // never rebuilt per request); `both` runs the two concurrently.
185
+ // Build one McpServer + registry per transport at boot (never
186
+ // rebuilt per request); `both` runs the two concurrently.
254
187
  if (wantStdio) {
255
- const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
188
+ const server = createMcpServer(pkg.version);
256
189
  const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
257
- const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
190
+ const stdioCtx = buildIdentityContext(process.env, { transport: 'stdio' });
191
+ const registry = buildRegistry(server, stdioCtx, undefined, stdioMetrics);
258
192
  registry.installListHandler();
259
193
  registerSetupPrompt(server);
260
- // Outbound runs outermost-first, so the composition is deliberate: the
261
- // envelope rewrite is INNERMOST (last to touch the frame), the tap sits
262
- // above it (classifying the ORIGINAL validation prose), arg normalization
263
- // outermost. The tap only counts outbound JSON-RPC error frames (no
264
- // handler ran) plus id->tool names.
265
- let transport = withValidationEnvelope(new StdioServerTransport(), {
266
- isKnownTool: (n) => registry.hasTool(n),
267
- defaultAccount: () => getAccountSet().defaultAccount,
268
- });
194
+ const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
195
+ // Composition order (outbound runs outermost-first): see docs/internals.md (Argument normalization).
196
+ let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
269
197
  if (stdioMetrics) {
270
198
  const { tapUsageMetrics } = await import('./metrics-tap.js');
271
199
  transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
272
200
  }
273
- const strictStdio = strictArgOptions(registry, stdioMetrics);
274
- await server.connect(argNormalizationEnabled() || strictStdio
201
+ await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
275
202
  ? withArgNormalization(transport,
276
- // Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
277
- // shape unconditionally made stdio rename keys while
278
- // GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
279
- // and failed on HTTP once screening rejects.
280
- argNormalizationEnabled() ? (n) => registry.argShape(n) : () => undefined, undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, strictStdio)
203
+ // Gated exactly as the HTTP leg gates argShapeFor, or GOOGLE_ARG_NORMALIZE=off
204
+ // would rename keys on stdio while HTTP's screening rejects the same call.
205
+ stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
281
206
  : transport);
282
207
  }
283
208
  if (wantHttp) {
284
209
  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
210
  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);
211
+ const gates = mtBootGates();
212
+ if (gates?.multiTenant) {
213
+ // Same fail-fast shape as below, but the gate is "a valid provisioning
214
+ // mechanism exists", not a flat owner allowlist.
215
+ gates.assertProvisioningGate();
216
+ }
217
+ else {
218
+ // The owner allowlist is the entire multi-tenant collapse;
219
+ // refuse to open an ungated HTTP endpoint.
220
+ if (owners.length === 0) {
221
+ 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');
222
+ process.exit(1);
223
+ }
291
224
  }
292
- // BR7: stateless HTTP cannot push tools/list_changed, so it forces curated.
225
+ // Stateless HTTP cannot push tools/list_changed, so it forces curated.
293
226
  const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
294
227
  if (configuredMode && configuredMode !== 'curated') {
295
228
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
296
229
  }
297
- const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
230
+ const httpServer = createMcpServer(pkg.version);
298
231
  const httpMetrics = initMetricsFor('http', 'curated');
299
- const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
232
+ const httpCtx = buildIdentityContext(process.env, { transport: 'http' });
233
+ const registry = buildRegistry(httpServer, httpCtx, 'curated', httpMetrics);
300
234
  registry.installListHandler();
301
235
  registerSetupPrompt(httpServer);
302
- // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
303
- const { buildAuthServer } = await import('./oauth-as.js');
236
+ // Mount the OAuth 2.1 AS (MCP-client tokens + owner login) + the Bearer authenticator.
237
+ const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
304
238
  const { jwtSecretFrom } = await import('./mcp-token.js');
305
239
  const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
306
240
  const { OAuth2Client } = await import('googleapis-common');
307
- const { writeToken } = await import('./token-store.js');
241
+ const { readToken, writeToken } = await import('./token-store.js');
308
242
  const { resolveScopesForAccount } = await import('./auth.js');
243
+ const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
244
+ const { scopeGrantDiff, setWizardHttpConsent } = await import('./tools/account-wizard.js');
309
245
  const { configDir } = await import('./config-file.js');
310
246
  const { getAccountSet } = await import('./accounts.js');
311
247
  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
248
  const authServer = buildAuthServer({
327
249
  base: httpCfg.publicUrl,
328
250
  resourceUri: httpCfg.resourceUri,
@@ -333,20 +255,7 @@ async function main() {
333
255
  masterKey: resolveMasterKeyForDispatch().key,
334
256
  refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
335
257
  }, {
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
- },
258
+ buildGoogleAuthUrl: ({ flow, alias, state }) => googleClient().generateAuthUrl(ownerAuthUrlOptions({ flow, alias, state }, (a) => resolveScopesForAccount(a))),
350
259
  exchangeCode: async (code, flow) => {
351
260
  const { tokens } = await googleClient().getToken(code);
352
261
  const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
@@ -355,13 +264,25 @@ async function main() {
355
264
  return { tokens: flow === 'owner_gate' ? {} : tokens, email };
356
265
  },
357
266
  writeToken: (alias, tokens) => writeToken(alias, tokens),
267
+ missingScopes: (alias, granted) => scopeGrantDiff(resolveScopesForAccount(alias), granted),
268
+ hasToken: (alias) => {
269
+ try {
270
+ return readToken(alias) != null;
271
+ }
272
+ catch {
273
+ return false; // undecryptable: nothing usable to keep
274
+ }
275
+ },
358
276
  aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
359
277
  log: (l) => process.stderr.write(`[as] ${l}\n`),
360
278
  });
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);
279
+ // Over HTTP, a dead/missing per-account token surfaces a clickable,
280
+ // signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
281
+ const { setHttpReauthLink } = await import('./reauth-hint.js');
282
+ setHttpReauthLink((alias) => authServer.reauthLink(alias));
283
+ // Wizard consent over HTTP: hand out the same signed link instead of
284
+ // binding a loopback listener.
285
+ setWizardHttpConsent({ mintConsentUrl: (alias) => authServer.reauthLink(alias) });
365
286
  let httpTap;
366
287
  if (httpMetrics) {
367
288
  const { tapUsageMetrics } = await import('./metrics-tap.js');
@@ -375,18 +296,13 @@ async function main() {
375
296
  authenticate: authServer.authenticate,
376
297
  routes: authServer.routes,
377
298
  log: (l) => process.stderr.write(`[http] ${l}\n`),
378
- argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
299
+ ...requestHooksFor(registry, httpCtx, httpMetrics),
379
300
  metricsTap: httpTap,
380
- validationEnvelope: {
381
- isKnownTool: (n) => registry.hasTool(n),
382
- defaultAccount: () => getAccountSet().defaultAccount,
383
- },
384
301
  onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
385
- strictArgs: strictArgOptions(registry, httpMetrics),
386
302
  });
387
303
  await host.start();
388
304
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
389
- // Graceful shutdown so `docker run --init` (B16) forwards SIGTERM cleanly.
305
+ // Graceful shutdown so `docker run --init` forwards SIGTERM cleanly.
390
306
  const shutdown = () => {
391
307
  host.close().finally(() => process.exit(0));
392
308
  };
@@ -15,7 +15,7 @@ export interface KeyDeps {
15
15
  hasAnyToken?: () => boolean;
16
16
  }
17
17
  export declare function __setKeychainFactoryForTest(fn: ((account: string) => KeychainEntry | null) | null): void;
18
- /** BR-1 single choke point: no other module may read the env vars directly.
18
+ /** Single choke point: no other module may read the env vars directly.
19
19
  * Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
20
20
  export declare function resolveMasterKey(deps?: KeyDeps): ResolvedKey;
21
21
  /** Dispatch form: identical resolution, but failures THROW (caught by the tool
@@ -28,14 +28,14 @@ export declare function peekMasterKeyProvenance(deps?: KeyDeps): KeyProvenance |
28
28
  * outstanding MCP tokens and clients re-auth silently. */
29
29
  export declare function resolveJwtKey(deps?: KeyDeps): ResolvedKey;
30
30
  /**
31
- * BR-5: after the FIRST successful token decrypt with an env-sourced key,
31
+ * After the FIRST successful token decrypt with an env-sourced key,
32
32
  * mirror it into the keychain (only when the keychain holds no entry) so the
33
33
  * key survives an env loss. Gating on a successful decrypt means a stale or
34
34
  * wrong env key is never mirrored. Best-effort: failures are doctor material,
35
35
  * never fatal.
36
36
  */
37
37
  export declare function noteSuccessfulDecrypt(): void;
38
- /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
38
+ /** Reset: drop the generated MASTER_KEY material (0600 file + keychain
39
39
  * entry) so the next run re-provisions. Never removes an env-provided key (the
40
40
  * deployer owns that; `env:true` flags it so reset can warn). The caller MUST
41
41
  * have wiped every .enc token first — regenerating with tokens present would
@@ -5,10 +5,8 @@ import { createRequire } from 'node:module';
5
5
  import { configDir, failStartup } from './config-file.js';
6
6
  import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
7
7
  import { getTokenDir } from './accounts.js';
8
- // The keychain is a native optionalDependency: load lazily and synchronously
9
- // (createRequire — the token store's key read is sync), and treat EVERY
10
- // failure (no prebuild, native load error, headless Linux without a Secret
11
- // Service) as "keychain unavailable", falling through to the 0600 file.
8
+ // Native optionalDependency, loaded lazily and synchronously (the token
9
+ // store's key read is sync); EVERY failure falls through to the 0600 file.
12
10
  const require_ = createRequire(import.meta.url);
13
11
  function systemKeychain(account) {
14
12
  try {
@@ -32,23 +30,24 @@ function systemKeychain(account) {
32
30
  }
33
31
  }
34
32
  // Default keychain factory, replaceable ONLY for tests: unit tests must never
35
- // read or write the developer's real OS keyring (the BR-5 mirror once leaked a
36
- // test fixture key into a real GNOME keyring — never again).
33
+ // read or write the developer's real OS keyring.
37
34
  let keychainFactory = systemKeychain;
38
35
  export function __setKeychainFactoryForTest(fn) {
39
36
  keychainFactory = fn ?? systemKeychain;
40
37
  }
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() {
38
+ function anyEncFileUnder(dir) {
45
39
  try {
46
- return fs.readdirSync(getTokenDir()).some((f) => f.endsWith('.enc'));
40
+ return fs.readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith('.enc'));
47
41
  }
48
42
  catch {
49
43
  return false;
50
44
  }
51
45
  }
46
+ // Globs the token dir rather than iterating registered aliases, so an orphan
47
+ // .enc still stops a fresh key from being minted. See docs/internals.md.
48
+ function anyTokenFileExists() {
49
+ return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
50
+ }
52
51
  function readKeyFile(filePath) {
53
52
  try {
54
53
  const raw = fs.readFileSync(filePath, 'utf8').trim();
@@ -90,7 +89,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
90
89
  const fromFile = readKeyFile(filePath);
91
90
  if (fromFile)
92
91
  return { key: fromFile, provenance: 'file' };
93
- // Generate-on-setup. The hard guard is cc-security Cgen: minting a fresh
92
+ // Generate-on-setup. The hard guard: minting a fresh
94
93
  // MASTER_KEY while <alias>.enc files exist would silently brick them all.
95
94
  if (hardGuard && hasAnyToken()) {
96
95
  fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
@@ -107,10 +106,8 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
107
106
  return { key: racedFile, provenance: 'file' };
108
107
  // Exactly randomBytes(32) base64: hits deriveKey's 32-byte fast path.
109
108
  const generated = randomBytes(32).toString('base64');
110
- // The 0600 file is ALWAYS written: on Linux the "keychain" can be the
111
- // kernel keyutils keyring, which does not survive a reboot — a key that
112
- // lives only there bricks every token at the next boot. The file is the
113
- // durable sink; byte-exact read-back gates any use of the key.
109
+ // The 0600 file is ALWAYS written (some keychain backends don't survive
110
+ // a reboot); byte-exact read-back gates any use of the key.
114
111
  atomicWriteFileSync(filePath, `${generated}\n`, 0o600);
115
112
  if (readKeyFile(filePath) !== generated) {
116
113
  fail('E_KEY_PROVISIONING_FAILED', `generated ${envName} could not be read back byte-exact from ${filePath}; refusing to encrypt anything with an unverified key.`);
@@ -143,7 +140,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
143
140
  }
144
141
  let cachedMaster = null;
145
142
  let cachedJwt = null;
146
- /** BR-1 single choke point: no other module may read the env vars directly.
143
+ /** Single choke point: no other module may read the env vars directly.
147
144
  * Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
148
145
  export function resolveMasterKey(deps) {
149
146
  if (deps)
@@ -181,7 +178,7 @@ export function resolveJwtKey(deps) {
181
178
  }
182
179
  let mirrored = false;
183
180
  /**
184
- * BR-5: after the FIRST successful token decrypt with an env-sourced key,
181
+ * After the FIRST successful token decrypt with an env-sourced key,
185
182
  * mirror it into the keychain (only when the keychain holds no entry) so the
186
183
  * key survives an env loss. Gating on a successful decrypt means a stale or
187
184
  * wrong env key is never mirrored. Best-effort: failures are doctor material,
@@ -203,7 +200,7 @@ export function noteSuccessfulDecrypt() {
203
200
  // Keychain backend unavailable; env stays the working source.
204
201
  }
205
202
  }
206
- /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
203
+ /** Reset: drop the generated MASTER_KEY material (0600 file + keychain
207
204
  * entry) so the next run re-provisions. Never removes an env-provided key (the
208
205
  * deployer owns that; `env:true` flags it so reset can warn). The caller MUST
209
206
  * have wiped every .enc token first — regenerating with tokens present would