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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +21 -16
  2. package/dist/accounts.d.ts +44 -5
  3. package/dist/accounts.js +267 -25
  4. package/dist/auth.d.ts +11 -4
  5. package/dist/auth.js +85 -78
  6. package/dist/client-config.d.ts +90 -0
  7. package/dist/client-config.js +282 -0
  8. package/dist/client.js +10 -4
  9. package/dist/config-file.d.ts +38 -0
  10. package/dist/config-file.js +117 -0
  11. package/dist/discover.js +57 -3
  12. package/dist/doctor.d.ts +86 -0
  13. package/dist/doctor.js +384 -0
  14. package/dist/env-load.d.ts +11 -0
  15. package/dist/env-load.js +52 -0
  16. package/dist/executor.d.ts +2 -0
  17. package/dist/executor.js +2 -1
  18. package/dist/fanout.js +8 -5
  19. package/dist/fs-atomic.d.ts +3 -0
  20. package/dist/fs-atomic.js +123 -0
  21. package/dist/http-config.d.ts +35 -0
  22. package/dist/http-config.js +113 -0
  23. package/dist/http-transport.d.ts +59 -0
  24. package/dist/http-transport.js +269 -0
  25. package/dist/identity.d.ts +24 -0
  26. package/dist/identity.js +19 -0
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +199 -18
  29. package/dist/master-key.d.ts +53 -0
  30. package/dist/master-key.js +242 -0
  31. package/dist/mcp-token.d.ts +81 -0
  32. package/dist/mcp-token.js +176 -0
  33. package/dist/migrate-config.d.ts +7 -0
  34. package/dist/migrate-config.js +103 -0
  35. package/dist/migrate-tokens.js +4 -3
  36. package/dist/net-tuning.d.ts +8 -0
  37. package/dist/net-tuning.js +25 -0
  38. package/dist/oauth-as.d.ts +52 -0
  39. package/dist/oauth-as.js +457 -0
  40. package/dist/oauth-consent.d.ts +28 -0
  41. package/dist/oauth-consent.js +109 -0
  42. package/dist/open-url.d.ts +1 -0
  43. package/dist/open-url.js +30 -0
  44. package/dist/reauth-hint.d.ts +2 -0
  45. package/dist/reauth-hint.js +15 -0
  46. package/dist/registry-transfer.d.ts +41 -0
  47. package/dist/registry-transfer.js +219 -0
  48. package/dist/registry.d.ts +27 -1
  49. package/dist/registry.js +117 -6
  50. package/dist/scope-catalog.d.ts +19 -0
  51. package/dist/scope-catalog.js +182 -0
  52. package/dist/scope-observability.d.ts +78 -0
  53. package/dist/scope-observability.js +192 -0
  54. package/dist/services.js +1 -1
  55. package/dist/setup-prompt.d.ts +9 -0
  56. package/dist/setup-prompt.js +59 -0
  57. package/dist/ssrf-guard.d.ts +48 -0
  58. package/dist/ssrf-guard.js +242 -0
  59. package/dist/token-store.js +23 -125
  60. package/dist/tools/_errors.d.ts +8 -2
  61. package/dist/tools/_errors.js +97 -6
  62. package/dist/tools/account-wizard.d.ts +33 -0
  63. package/dist/tools/account-wizard.js +284 -0
  64. package/dist/tools/accounts-tool.d.ts +8 -5
  65. package/dist/tools/accounts-tool.js +38 -15
  66. package/dist/tools/admin.js +2 -2
  67. package/dist/tools/calendar.js +2 -2
  68. package/dist/tools/chat.js +2 -2
  69. package/dist/tools/contacts.d.ts +40 -0
  70. package/dist/tools/contacts.js +135 -2
  71. package/dist/tools/docs.d.ts +3 -0
  72. package/dist/tools/docs.js +60 -9
  73. package/dist/tools/drive.d.ts +3 -0
  74. package/dist/tools/drive.js +60 -8
  75. package/dist/tools/forms.js +4 -2
  76. package/dist/tools/generated/_shared.d.ts +1 -3
  77. package/dist/tools/generated/_shared.js +3 -3
  78. package/dist/tools/generated/admin.js +175 -132
  79. package/dist/tools/generated/appsmarket.js +6 -2
  80. package/dist/tools/generated/calendar.js +45 -27
  81. package/dist/tools/generated/chat.js +81 -47
  82. package/dist/tools/generated/classroom.js +131 -104
  83. package/dist/tools/generated/cloudidentity.js +73 -61
  84. package/dist/tools/generated/cloudsearch.js +59 -49
  85. package/dist/tools/generated/contacts.js +22 -14
  86. package/dist/tools/generated/drive.js +51 -35
  87. package/dist/tools/generated/driveactivity.js +5 -1
  88. package/dist/tools/generated/drivelabels.js +30 -25
  89. package/dist/tools/generated/forms.js +7 -3
  90. package/dist/tools/generated/gmail.js +73 -58
  91. package/dist/tools/generated/groupsmigration.js +5 -1
  92. package/dist/tools/generated/groupssettings.js +7 -3
  93. package/dist/tools/generated/keep.js +12 -7
  94. package/dist/tools/generated/licensing.js +11 -7
  95. package/dist/tools/generated/meet.js +20 -13
  96. package/dist/tools/generated/postmaster.js +8 -4
  97. package/dist/tools/generated/reseller.js +22 -17
  98. package/dist/tools/generated/script.js +26 -16
  99. package/dist/tools/generated/sheets.js +11 -7
  100. package/dist/tools/generated/tasks.js +6 -2
  101. package/dist/tools/generated/vault.js +38 -33
  102. package/dist/tools/generated/workspaceevents.js +12 -7
  103. package/dist/tools/gmail-mime.d.ts +38 -8
  104. package/dist/tools/gmail-mime.js +75 -71
  105. package/dist/tools/gmail.d.ts +31 -0
  106. package/dist/tools/gmail.js +483 -99
  107. package/dist/tools/google-api.js +7 -4
  108. package/dist/tools/meet.js +2 -2
  109. package/dist/tools/searchconsole.js +2 -2
  110. package/dist/tools/sheets.js +6 -2
  111. package/dist/tools/slides.js +4 -2
  112. package/dist/tools/tasks.js +2 -2
  113. package/dist/types.d.ts +1 -1
  114. package/dist/write-control.d.ts +24 -1
  115. package/dist/write-control.js +26 -1
  116. package/package.json +26 -7
@@ -0,0 +1,269 @@
1
+ // B12: the Streamable HTTP transport host (cc-transport-hosting T2/T3). Owns the
2
+ // node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
3
+ // and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
4
+ // verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
5
+ // loopback-owner authenticator so the local-HTTP model works before the AS lands.
6
+ import { createServer } from 'node:http';
7
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
+ // A hung handler that keeps the connection open would otherwise hold the global
9
+ // serialize() lock forever. Generous by default so slow-but-valid calls (large
10
+ // Drive exports, fan-out) still finish; the point is only to guarantee release.
11
+ const DISPATCH_TIMEOUT_DEFAULT = 120_000;
12
+ export function parseOwnerEmails(env = process.env) {
13
+ return (env.MCP_OWNER_EMAILS ?? '')
14
+ .split(',')
15
+ .map((s) => s.trim().toLowerCase())
16
+ .filter(Boolean);
17
+ }
18
+ /** Front guard: an Origin, if present, must be allowlisted; a Host must be
19
+ * allowlisted. Absent Origin passes (native/CLI/backend clients — the claude.ai
20
+ * connector calls /mcp server-to-server with no Origin, BV-4). */
21
+ export function originAllowed(origin, allowed) {
22
+ if (!origin)
23
+ return true;
24
+ return allowed.includes(origin);
25
+ }
26
+ export function hostAllowed(host, allowed) {
27
+ if (allowed.length === 0)
28
+ return true;
29
+ if (!host)
30
+ return false;
31
+ // Port-agnostic, matching the SDK's DNS-rebind guard: allow an exact match or
32
+ // the bare hostname (allowedHosts carries both host[:port] and bare forms).
33
+ const bare = host.replace(/:\d+$/, '');
34
+ return allowed.includes(host) || allowed.includes(bare);
35
+ }
36
+ /** JSON-RPC method name(s) for an observability log line — no params, no PII. */
37
+ export function jsonRpcMethod(body) {
38
+ const one = (b) => b && typeof b === 'object' && 'method' in b ? String(b.method) : undefined;
39
+ if (Array.isArray(body))
40
+ return body.map(one).filter(Boolean).join(',') || 'batch';
41
+ return one(body) ?? 'unknown';
42
+ }
43
+ export class HttpTransportHost {
44
+ opts;
45
+ httpServer;
46
+ // Serialize the connect→dispatch critical section: the shared McpServer
47
+ // captures its transport per request (protocol.js), but the gap between
48
+ // connect() and the dispatch capturing it would still race under true
49
+ // concurrency. Single-owner HTTP traffic is effectively serial, so a mutex
50
+ // keeps correctness at negligible cost (and never rebuilds the registry).
51
+ lock = Promise.resolve();
52
+ constructor(opts) {
53
+ this.opts = opts;
54
+ }
55
+ serialize(fn) {
56
+ const run = this.lock.then(fn, fn);
57
+ this.lock = run.then(() => undefined, () => undefined);
58
+ return run;
59
+ }
60
+ log(line) {
61
+ this.opts.log?.(line);
62
+ }
63
+ async start() {
64
+ const { config } = this.opts;
65
+ const server = createServer((req, res) => {
66
+ this.handle(req, res).catch((e) => this.fail(res, 500, 'internal_error', e.message));
67
+ });
68
+ // Slow-loris mitigation behind the tunnel.
69
+ server.headersTimeout = 15_000;
70
+ server.requestTimeout = 30_000;
71
+ this.httpServer = server;
72
+ await new Promise((resolve, reject) => {
73
+ const onErr = (e) => reject(e);
74
+ server.once('error', onErr);
75
+ server.listen(config.port, config.host, () => {
76
+ server.off('error', onErr);
77
+ resolve();
78
+ });
79
+ });
80
+ this.log(`listening on ${config.host}:${config.port} (public ${config.publicUrl}, transport ${config.transport})`);
81
+ }
82
+ async close() {
83
+ const s = this.httpServer;
84
+ if (!s)
85
+ return;
86
+ await new Promise((resolve) => {
87
+ s.close(() => resolve());
88
+ s.closeAllConnections?.();
89
+ });
90
+ this.httpServer = undefined;
91
+ }
92
+ /** The bound port (useful when listening on port 0 in tests). */
93
+ address() {
94
+ const a = this.httpServer?.address();
95
+ return a && typeof a === 'object' ? { port: a.port } : undefined;
96
+ }
97
+ async handle(req, res) {
98
+ // req.url is the path+query; parse against a FIXED base so a malformed/hostile
99
+ // Host header can't throw here (the Host is validated in the front guard).
100
+ const url = new URL(req.url ?? '/', 'http://localhost');
101
+ const path = url.pathname;
102
+ if (path === '/health') {
103
+ if (req.method !== 'GET') {
104
+ res.setHeader('Allow', 'GET');
105
+ return this.fail(res, 405, 'method_not_allowed', 'GET /health only');
106
+ }
107
+ return this.health(res);
108
+ }
109
+ const route = this.opts.routes?.[path];
110
+ if (route) {
111
+ if (!this.frontGuard(req, res))
112
+ return;
113
+ const handled = await route(req, res, url);
114
+ if (!handled && !res.writableEnded)
115
+ this.fail(res, 404, 'not_found', `No handler for ${path}`);
116
+ return;
117
+ }
118
+ if (path === '/mcp')
119
+ return this.mcp(req, res);
120
+ this.fail(res, 404, 'not_found', `Unknown path ${path}`);
121
+ }
122
+ frontGuard(req, res) {
123
+ const { allowedHosts, allowedOrigins } = this.opts.config;
124
+ if (!hostAllowed(req.headers.host, allowedHosts)) {
125
+ this.log(`403 host_rejected host=${req.headers.host ?? ''}`);
126
+ this.fail(res, 403, 'host_rejected', 'Host not allowed (DNS-rebinding guard).');
127
+ return false;
128
+ }
129
+ if (!originAllowed(req.headers.origin, allowedOrigins)) {
130
+ this.log(`403 origin_rejected origin=${req.headers.origin ?? ''}`);
131
+ this.fail(res, 403, 'origin_rejected', 'Origin not allowed.');
132
+ return false;
133
+ }
134
+ return true;
135
+ }
136
+ async mcp(req, res) {
137
+ if (req.method !== 'POST') {
138
+ res.setHeader('Allow', 'POST');
139
+ return this.fail(res, 405, 'method_not_allowed', 'POST /mcp only (stateless mode; no GET SSE).');
140
+ }
141
+ if (!this.frontGuard(req, res))
142
+ return;
143
+ const auth = await this.opts.authenticate(req);
144
+ if (!auth.ok) {
145
+ for (const [k, v] of Object.entries(auth.headers ?? {}))
146
+ res.setHeader(k, v);
147
+ res.writeHead(auth.status, { 'Content-Type': 'application/json' });
148
+ res.end(auth.body);
149
+ this.log(`${auth.status} auth_failed path=/mcp`);
150
+ return;
151
+ }
152
+ let body;
153
+ try {
154
+ body = await this.readJson(req);
155
+ }
156
+ catch (e) {
157
+ return this.fail(res, 400, 'invalid_body', e.message);
158
+ }
159
+ // Host/Origin are enforced by the front guard above (uniformly for /mcp and
160
+ // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
161
+ // exact-Host match is stricter than the front guard and would 403 valid
162
+ // Hosts (double enforcement, differing rules).
163
+ const transport = new StreamableHTTPServerTransport({
164
+ sessionIdGenerator: undefined,
165
+ enableJsonResponse: true,
166
+ enableDnsRebindingProtection: false,
167
+ });
168
+ // The shared McpServer holds exactly one connected transport at a time, so
169
+ // connect + dispatch + DISCONNECT all run inside the mutex: the next request
170
+ // can never hit "Already connected". The serialized section is guaranteed to
171
+ // settle three ways — the handler finishes, the client disconnects (res
172
+ // 'close'), or a dispatch deadline fires — so neither a client that holds the
173
+ // connection open nor a hung upstream can wedge the lock. The transport is
174
+ // closed in a finally (which resets server._transport) before the lock
175
+ // releases. (A shared initialized-state persists across stateless requests;
176
+ // benign for the single-owner design.)
177
+ const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
178
+ await this.serialize(async () => {
179
+ const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
180
+ let timer;
181
+ const timedOut = new Promise((resolve) => {
182
+ timer = setTimeout(() => resolve('timeout'), deadlineMs);
183
+ timer.unref?.();
184
+ });
185
+ await this.opts.server.connect(transport);
186
+ // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
187
+ // race, an orphaned handler settling later must not surface as an unhandled
188
+ // rejection — but a genuine dispatch error still propagates (rethrown below).
189
+ let dispatchErr;
190
+ const dispatchArm = transport
191
+ .handleRequest(req, res, body)
192
+ .then(() => 'done', (e) => {
193
+ dispatchErr = e;
194
+ return 'error';
195
+ });
196
+ try {
197
+ const outcome = await Promise.race([dispatchArm, disconnected, timedOut]);
198
+ if (outcome === 'error')
199
+ throw dispatchErr;
200
+ if (outcome === 'timeout') {
201
+ this.log('504 dispatch_timeout path=/mcp');
202
+ if (!res.headersSent) {
203
+ res.writeHead(504, { 'Content-Type': 'application/json' });
204
+ res.end(JSON.stringify({ error: 'dispatch_timeout', message: 'the handler exceeded the dispatch deadline' }));
205
+ }
206
+ else {
207
+ res.destroy();
208
+ }
209
+ }
210
+ else if (outcome === 'done') {
211
+ // Success path: close the observability gap (failures already log; a
212
+ // completed dispatch logged nothing). Method only — no params, no PII.
213
+ this.log(`200 /mcp method=${jsonRpcMethod(body)}`);
214
+ }
215
+ }
216
+ finally {
217
+ if (timer)
218
+ clearTimeout(timer);
219
+ await transport.close().catch(() => undefined);
220
+ }
221
+ });
222
+ }
223
+ health(res) {
224
+ const { config, ownerConfigured, version } = this.opts;
225
+ res.writeHead(200, { 'Content-Type': 'application/json' });
226
+ // No secrets, no owner emails (cc-transport-hosting /health rule).
227
+ res.end(JSON.stringify({
228
+ status: 'ok',
229
+ transport: config.transport,
230
+ publicUrl: config.publicUrl,
231
+ ownerConfigured,
232
+ version,
233
+ }));
234
+ }
235
+ readJson(req) {
236
+ const max = this.opts.maxBodyBytes ?? 4_000_000;
237
+ return new Promise((resolve, reject) => {
238
+ let size = 0;
239
+ const chunks = [];
240
+ req.on('data', (c) => {
241
+ size += c.length;
242
+ if (size > max) {
243
+ reject(new Error('request body too large'));
244
+ req.destroy();
245
+ return;
246
+ }
247
+ chunks.push(c);
248
+ });
249
+ req.on('end', () => {
250
+ const raw = Buffer.concat(chunks).toString('utf-8');
251
+ if (raw.trim() === '')
252
+ return resolve(undefined);
253
+ try {
254
+ resolve(JSON.parse(raw));
255
+ }
256
+ catch {
257
+ reject(new Error('request body is not valid JSON'));
258
+ }
259
+ });
260
+ req.on('error', reject);
261
+ });
262
+ }
263
+ fail(res, status, error, message) {
264
+ if (res.writableEnded)
265
+ return;
266
+ res.writeHead(status, { 'Content-Type': 'application/json' });
267
+ res.end(JSON.stringify({ error, message }));
268
+ }
269
+ }
@@ -0,0 +1,24 @@
1
+ import type { AccountSet } from './accounts.js';
2
+ import { getClient } from './client.js';
3
+ import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
4
+ import { type Policy, type Transport } from './write-control.js';
5
+ /**
6
+ * The one forward-compat seam (frozen public API): the free core builds
7
+ * exactly one context with subject "owner"; EE later instantiates N contexts
8
+ * from N registries. Nothing here may assume a global current-account.
9
+ */
10
+ export interface IdentityContext {
11
+ subject: 'owner';
12
+ accounts: AccountSet;
13
+ policy: Policy;
14
+ getClient: typeof getClient;
15
+ tokenStore: {
16
+ readToken: typeof readToken;
17
+ writeToken: typeof writeToken;
18
+ updateToken: typeof updateToken;
19
+ hasToken: typeof hasToken;
20
+ };
21
+ }
22
+ export declare function buildIdentityContext(env?: NodeJS.ProcessEnv, opts?: {
23
+ transport?: Transport;
24
+ }): IdentityContext;
@@ -0,0 +1,19 @@
1
+ import { getAccountSet } from './accounts.js';
2
+ import { getClient } from './client.js';
3
+ import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
4
+ import { resolvePolicy } from './write-control.js';
5
+ export function buildIdentityContext(env = process.env, opts = {}) {
6
+ return {
7
+ subject: 'owner',
8
+ // Live getter: the seam must always see the current registry, never a
9
+ // snapshot pinned from before a wizard mutation or cross-process reload.
10
+ get accounts() {
11
+ return getAccountSet();
12
+ },
13
+ // The dispatch transport rides into the resolved policy as a reserved seam
14
+ // (cc-write-control B14); it does not change any write-control verdict.
15
+ policy: resolvePolicy(env, { transport: opts.transport }),
16
+ getClient,
17
+ tokenStore: { readToken, writeToken, updateToken, hasToken },
18
+ };
19
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import './accounts.js';
2
+ export {};
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import './accounts.js';
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 } from './accounts.js';
3
5
  import { readFileSync } from 'node:fs';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import path from 'node:path';
@@ -11,12 +13,19 @@ import { ToolRegistry } from './registry.js';
11
13
  import { registerDiscoverTools } from './discover.js';
12
14
  import { registerEscapeTools } from './tools/google-api.js';
13
15
  import { registerAccountTools } from './tools/accounts-tool.js';
16
+ import { registerDiagnoseTool } from './doctor.js';
17
+ import { registerAccountWizardTools } from './tools/account-wizard.js';
14
18
  import { getToolsets, toolsetEnabled } from './toolsets.js';
15
- import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
19
+ import { isAllowed, describePolicy } from './write-control.js';
20
+ import { buildIdentityContext } from './identity.js';
21
+ import { registerSetupPrompt } from './setup-prompt.js';
22
+ import { applyNetTuning } from './net-tuning.js';
23
+ applyNetTuning();
16
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
25
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
18
- function buildRegistry(server, policy) {
19
- const registry = new ToolRegistry(server, policy);
26
+ function buildRegistry(server, ctx, mode) {
27
+ const policy = ctx.policy;
28
+ const registry = new ToolRegistry(server, policy, mode);
20
29
  const toolsets = getToolsets();
21
30
  if (toolsets !== 'all') {
22
31
  const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
@@ -31,7 +40,7 @@ function buildRegistry(server, policy) {
31
40
  continue;
32
41
  if (svc.enabled && !svc.enabled()) {
33
42
  if (toolsets !== 'all') {
34
- const hint = svc.name === 'admin' ? 'set GOOGLE_ADMIN_ACCOUNTS' : `add "${svc.name}" to GOOGLE_OPTIONAL_SCOPES`;
43
+ 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)`;
35
44
  process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
36
45
  }
37
46
  continue;
@@ -55,11 +64,13 @@ function buildRegistry(server, policy) {
55
64
  const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
56
65
  throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
57
66
  `Known services: ${known.join(', ')}. ` +
58
- `Note: optional services require their bundle in GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
67
+ `Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
59
68
  }
60
69
  registerDiscoverTools(registry, policy);
61
70
  registerEscapeTools(registry, policy);
62
71
  registerAccountTools(registry);
72
+ registerDiagnoseTool(registry);
73
+ registerAccountWizardTools(registry, server);
63
74
  return registry;
64
75
  }
65
76
  async function main() {
@@ -73,9 +84,40 @@ async function main() {
73
84
  runMigrateTokens();
74
85
  return;
75
86
  }
87
+ if (process.argv.includes('migrate-config')) {
88
+ const { runMigrateConfig } = await import('./migrate-config.js');
89
+ runMigrateConfig();
90
+ return;
91
+ }
92
+ if (process.argv.includes('doctor')) {
93
+ const { runDoctorCli } = await import('./doctor.js');
94
+ process.exitCode = await runDoctorCli(process.argv, pkg.version);
95
+ return;
96
+ }
97
+ if (process.argv.includes('reset')) {
98
+ const { runResetCli } = await import('./doctor.js');
99
+ process.exitCode = await runResetCli(process.argv);
100
+ return;
101
+ }
102
+ if (process.argv.includes('write-client-config')) {
103
+ const { runWriteClientConfigCli } = await import('./client-config.js');
104
+ process.exitCode = await runWriteClientConfigCli(process.argv);
105
+ return;
106
+ }
107
+ if (process.argv.includes('account') && process.argv.includes('export')) {
108
+ const { runExportCli } = await import('./registry-transfer.js');
109
+ process.exitCode = await runExportCli(process.argv);
110
+ return;
111
+ }
112
+ if (process.argv.includes('account') && process.argv.includes('import')) {
113
+ const { runImportCli } = await import('./registry-transfer.js');
114
+ process.exitCode = await runImportCli(process.argv);
115
+ return;
116
+ }
76
117
  if (process.argv.includes('config') && process.argv.includes('check')) {
77
- const policy = resolvePolicy();
78
- const registry = buildRegistry(new McpServer({ name: 'mcp-google-multi', version: pkg.version }), policy);
118
+ const ctx = buildIdentityContext();
119
+ const policy = ctx.policy;
120
+ const registry = buildRegistry(new McpServer({ name: 'mcp-google-multi', version: pkg.version }), ctx);
79
121
  const cud = registry.tools.filter((t) => t.cud !== 'read');
80
122
  const disabled = cud.filter((t) => !isAllowed(t, policy));
81
123
  const counts = registry.visibleCount();
@@ -96,19 +138,158 @@ async function main() {
96
138
  }
97
139
  console.log(`Disabled: ${disabledLine}`);
98
140
  console.log(`Services: ${registry.services().join(', ')}`);
99
- console.log(`Tool surface: ${counts.eager} eager (discover + escape hatch), ${counts.hidden} deferred until discovery`);
141
+ console.log(`Discovery mode: ${registry.mode}${registry.mode === 'lazy' ? ' (expand at runtime with discover_all)' : ''}`);
142
+ console.log(`Tool surface: ${counts.eager} eager (discover + escape hatch), ${counts.revealed} advertised, ${counts.hidden} deferred`);
100
143
  console.log(`Escape hatch: google_api_call CUD verdicts follow profile=${policy.profile} and your allow/deny globs`);
144
+ const { getAccountSet } = await import('./accounts.js');
145
+ const set = getAccountSet();
146
+ console.log(`Default account: ${set.defaultAccount ? `${set.defaultAccount} (${set.defaultAccountSource})` : '(none — "account" is required per call)'}`);
147
+ const { peekMasterKeyProvenance } = await import('./master-key.js');
148
+ const prov = peekMasterKeyProvenance();
149
+ console.log(`MASTER_KEY: ${prov === 'unprovisioned' ? 'unprovisioned (will be generated on first use)' : prov}`);
150
+ try {
151
+ const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
152
+ const http = resolveHttpConfig();
153
+ console.log(`Transport: ${http.transport}`);
154
+ if (transportIncludesHttp(http.transport)) {
155
+ console.log(` HTTP bind: ${http.host}:${http.port}`);
156
+ console.log(` Public URL: ${http.publicUrl} (resource ${http.resourceUri})`);
157
+ console.log(` Allowed hosts: ${http.allowedHosts.join(', ')}`);
158
+ console.log(` Allowed origins: ${http.allowedOrigins.join(', ')}`);
159
+ }
160
+ }
161
+ catch (e) {
162
+ console.log(`Transport: (config error) ${e.message}`);
163
+ }
101
164
  return;
102
165
  }
103
- const policy = resolvePolicy();
104
- const server = new McpServer({
105
- name: 'mcp-google-multi',
106
- version: pkg.version,
107
- });
108
- const registry = buildRegistry(server, policy);
109
- registry.installListHandler();
110
- const transport = new StdioServerTransport();
111
- await server.connect(transport);
166
+ // BR-4: the SERVER never boots with an empty registry. The bootstrap and
167
+ // diagnostic CLIs above already returned; a fresh user configures accounts
168
+ // (env / migrate-config / account import / auth) before the server runs.
169
+ assertServerAccountsConfigured();
170
+ // Resolve (or provision) the master key BEFORE serving: the hard guard is
171
+ // specced "fatal at startup", never mid-dispatch.
172
+ const { resolveMasterKey } = await import('./master-key.js');
173
+ resolveMasterKey();
174
+ const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
175
+ let httpCfg;
176
+ try {
177
+ httpCfg = resolveHttpConfig();
178
+ }
179
+ catch (e) {
180
+ process.stderr.write(`Fatal: ${e.message}\n`);
181
+ process.exit(1);
182
+ }
183
+ const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
184
+ const wantHttp = transportIncludesHttp(httpCfg.transport);
185
+ // Build one McpServer + registry per transport at boot (P1 / BV gap #4:
186
+ // never rebuilt per request); `both` runs the two concurrently.
187
+ if (wantStdio) {
188
+ const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
189
+ const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
190
+ registry.installListHandler();
191
+ registerSetupPrompt(server);
192
+ await server.connect(new StdioServerTransport());
193
+ }
194
+ if (wantHttp) {
195
+ const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
196
+ // BR3 / C13: the owner allowlist is the entire multi-tenant collapse; refuse
197
+ // to open an ungated HTTP endpoint.
198
+ const owners = parseOwnerEmails(process.env);
199
+ if (owners.length === 0) {
200
+ 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');
201
+ process.exit(1);
202
+ }
203
+ // BR7: stateless HTTP cannot push tools/list_changed, so it forces curated.
204
+ const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
205
+ if (configuredMode && configuredMode !== 'curated') {
206
+ process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
207
+ }
208
+ const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
209
+ const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated');
210
+ registry.installListHandler();
211
+ registerSetupPrompt(httpServer);
212
+ // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
213
+ const { buildAuthServer } = await import('./oauth-as.js');
214
+ const { jwtSecretFrom } = await import('./mcp-token.js');
215
+ const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
216
+ const { OAuth2Client } = await import('googleapis-common');
217
+ const { writeToken } = await import('./token-store.js');
218
+ const { resolveScopesForAccount } = await import('./auth.js');
219
+ const { configDir } = await import('./config-file.js');
220
+ const { getAccountSet } = await import('./accounts.js');
221
+ const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
222
+ // Only trust the id_token email when Google marks it verified (#8).
223
+ const verifiedEmailFromIdToken = (idToken) => {
224
+ if (!idToken)
225
+ return undefined;
226
+ try {
227
+ const [, payload] = idToken.split('.');
228
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
229
+ const verified = claims.email_verified === true || claims.email_verified === 'true';
230
+ return verified ? claims.email : undefined;
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ };
236
+ const authServer = buildAuthServer({
237
+ base: httpCfg.publicUrl,
238
+ resourceUri: httpCfg.resourceUri,
239
+ secret: jwtSecretFrom(resolveJwtKey().key),
240
+ ownerEmails: owners,
241
+ cimdIssuers: (process.env.MCP_CIMD_ALLOWED_ISSUERS ?? 'claude.ai').split(',').map((s) => s.trim()).filter(Boolean),
242
+ accessTtlSec: Number(process.env.MCP_ACCESS_TTL) || undefined,
243
+ masterKey: resolveMasterKeyForDispatch().key,
244
+ refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
245
+ }, {
246
+ buildGoogleAuthUrl: ({ flow, alias, state }) => {
247
+ if (flow === 'alias_reauth' && alias) {
248
+ const cfg = getAccountSet().configs[alias];
249
+ // openid+email so /callback can bind the returned identity to the alias.
250
+ return googleClient().generateAuthUrl({
251
+ access_type: 'offline',
252
+ prompt: 'consent',
253
+ scope: [...new Set([...resolveScopesForAccount(alias), 'openid', 'email'])],
254
+ login_hint: cfg?.email,
255
+ state,
256
+ });
257
+ }
258
+ return googleClient().generateAuthUrl({ scope: ['openid', 'email'], prompt: 'select_account', state });
259
+ },
260
+ exchangeCode: async (code, flow) => {
261
+ const { tokens } = await googleClient().getToken(code);
262
+ const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
263
+ // owner_gate discards Google tokens (identity proof only); alias_reauth
264
+ // keeps them but still needs the email for the identity binding.
265
+ return { tokens: flow === 'owner_gate' ? {} : tokens, email };
266
+ },
267
+ writeToken: (alias, tokens) => writeToken(alias, tokens),
268
+ aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
269
+ log: (l) => process.stderr.write(`[as] ${l}\n`),
270
+ });
271
+ // BV-1: over HTTP, a dead/missing per-account token surfaces a clickable
272
+ // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
273
+ const { setHttpReauthBase } = await import('./reauth-hint.js');
274
+ setHttpReauthBase(httpCfg.publicUrl);
275
+ const host = new HttpTransportHost({
276
+ server: httpServer,
277
+ config: httpCfg,
278
+ version: pkg.version,
279
+ ownerConfigured: owners.length > 0,
280
+ authenticate: authServer.authenticate,
281
+ routes: authServer.routes,
282
+ log: (l) => process.stderr.write(`[http] ${l}\n`),
283
+ });
284
+ await host.start();
285
+ process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
286
+ // Graceful shutdown so `docker run --init` (B16) forwards SIGTERM cleanly.
287
+ const shutdown = () => {
288
+ host.close().finally(() => process.exit(0));
289
+ };
290
+ process.once('SIGTERM', shutdown);
291
+ process.once('SIGINT', shutdown);
292
+ }
112
293
  }
113
294
  main().catch((err) => {
114
295
  process.stderr.write(`Fatal error: ${err.message}\n`);
@@ -0,0 +1,53 @@
1
+ export type KeyProvenance = 'env' | 'keychain' | 'file' | 'generated';
2
+ export interface ResolvedKey {
3
+ key: string;
4
+ provenance: KeyProvenance;
5
+ }
6
+ interface KeychainEntry {
7
+ get(): string | null;
8
+ set(value: string): void;
9
+ del(): void;
10
+ }
11
+ export interface KeyDeps {
12
+ env?: NodeJS.ProcessEnv;
13
+ dir?: string;
14
+ keychain?: (account: string) => KeychainEntry | null;
15
+ hasAnyToken?: () => boolean;
16
+ }
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.
19
+ * Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
20
+ export declare function resolveMasterKey(deps?: KeyDeps): ResolvedKey;
21
+ /** Dispatch form: identical resolution, but failures THROW (caught by the tool
22
+ * handler into an error envelope) — a live server is never process.exit'd. */
23
+ export declare function resolveMasterKeyForDispatch(): ResolvedKey;
24
+ /** Side-effect-free provenance probe for diagnostics: NEVER generates, never
25
+ * fires the hard guard (config check must not mint keys or exit). */
26
+ export declare function peekMasterKeyProvenance(deps?: KeyDeps): KeyProvenance | 'unprovisioned';
27
+ /** MCP_JWT_KEY variant: NO hard guard — regenerating only invalidates
28
+ * outstanding MCP tokens and clients re-auth silently. */
29
+ export declare function resolveJwtKey(deps?: KeyDeps): ResolvedKey;
30
+ /**
31
+ * BR-5: after the FIRST successful token decrypt with an env-sourced key,
32
+ * mirror it into the keychain (only when the keychain holds no entry) so the
33
+ * key survives an env loss. Gating on a successful decrypt means a stale or
34
+ * wrong env key is never mirrored. Best-effort: failures are doctor material,
35
+ * never fatal.
36
+ */
37
+ export declare function noteSuccessfulDecrypt(): void;
38
+ /** B6 reset: drop the generated MASTER_KEY material (0600 file + keychain
39
+ * entry) so the next run re-provisions. Never removes an env-provided key (the
40
+ * deployer owns that; `env:true` flags it so reset can warn). The caller MUST
41
+ * have wiped every .enc token first — regenerating with tokens present would
42
+ * brick them (enforced by the reset planner). */
43
+ export declare function deleteMasterKeyMaterial(deps?: {
44
+ dir?: string;
45
+ keychain?: (a: string) => KeychainEntry | null;
46
+ }): {
47
+ file: boolean;
48
+ keychain: boolean;
49
+ env: boolean;
50
+ };
51
+ /** Test hook: the production cache is process-lifetime by design. */
52
+ export declare function clearKeyCacheForTest(): void;
53
+ export {};