mcp-google-multi 6.0.0-alpha.54 → 6.0.0-alpha.56

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.
@@ -20,6 +20,7 @@ export interface AccountSet {
20
20
  }
21
21
  export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw', opts?: {
22
22
  tokenDir?: string;
23
+ emptyOk?: boolean;
23
24
  }): AccountSet;
24
25
  export declare function fileStamp(filePath: string, version: number): string;
25
26
  /** Live accessor: dispatch-time readers use this, never a captured snapshot. */
package/dist/accounts.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import { z } from 'zod';
4
4
  import { loadEnvFiles } from './env-load.js';
5
5
  import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
6
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
6
+ import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
7
7
  const envLoad = loadEnvFiles();
8
8
  const defaultTokenDir = path.join(configDir(), 'tokens');
9
9
  const tokenDir = process.env.TOKEN_STORE_PATH
@@ -74,7 +74,10 @@ function noAccountsMessage() {
74
74
  * empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
75
75
  */
76
76
  let warnedLegacy = false;
77
- export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit', opts = {}) {
77
+ export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit',
78
+ // emptyOk: zero accounts is a valid set, not an error, even with 'throw'
79
+ // (a context that has linked nothing yet). An invalid file still fails.
80
+ opts = {}) {
78
81
  const dir = opts.tokenDir ?? tokenDir;
79
82
  const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
80
83
  const rawEnv = env.GOOGLE_ACCOUNTS;
@@ -92,7 +95,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
92
95
  if (bundle === 'admin') {
93
96
  fail('E_UNKNOWN_BUNDLE', '"admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
94
97
  }
95
- if (!(bundle in BUNDLE_CATALOG)) {
98
+ if (!isKnownBundle(bundle)) {
96
99
  const hint = closestBundle(bundle);
97
100
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
98
101
  }
@@ -130,7 +133,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
130
133
  // empty (BR-4), enforced by assertServerAccountsConfigured() in index.ts.
131
134
  // The dispatch-path reload ('throw') still throws so a mid-session emptied
132
135
  // config.json keeps the last-good registry (BR-7) instead of dropping tools.
133
- if (onInvalid === 'throw') {
136
+ if (onInvalid === 'throw' && !opts.emptyOk) {
134
137
  throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
135
138
  }
136
139
  return {
@@ -149,7 +152,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
149
152
  for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
150
153
  const bundles = resolveBundleAliases(profile.bundles);
151
154
  for (const bundle of bundles) {
152
- if (!(bundle in BUNDLE_CATALOG)) {
155
+ if (!isKnownBundle(bundle)) {
153
156
  const hint = closestBundle(bundle);
154
157
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
155
158
  }
package/dist/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { openUrl } from './open-url.js';
3
3
  import { ACCOUNTS, getAccountSet } from './accounts.js';
4
- import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
4
+ import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
5
5
  import { resolveMasterKey } from './master-key.js';
6
6
  import { writeToken } from './token-store.js';
7
7
  import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
@@ -48,7 +48,7 @@ function legacyGlobalProfile() {
48
48
  if (bundle === 'admin') {
49
49
  throw new Error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
50
50
  }
51
- if (!(bundle in BUNDLE_CATALOG)) {
51
+ if (!isKnownBundle(bundle)) {
52
52
  const hint = closestBundle(bundle);
53
53
  throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
54
54
  }
package/dist/compose.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
+ import type { ServerTarget } from './http-transport.js';
2
3
  import { ToolRegistry, type DiscoveryMode } from './registry.js';
3
4
  import { type IdentityContext } from './identity.js';
4
5
  import type { Metrics } from './usage-metrics.js';
5
6
  export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
7
+ /** The per-request hooks bound to one registry: argument shapes, unknown-
8
+ * argument screening and the account a validation envelope names. Both
9
+ * transports and every resolved server target build them here, so a mistyped
10
+ * argument behaves the same wherever it arrives. */
11
+ export declare function requestHooksFor(registry: ToolRegistry, ctx: Pick<IdentityContext, 'accounts'>, metrics?: Metrics | null, env?: NodeJS.ProcessEnv): Omit<ServerTarget, 'server'>;
package/dist/compose.js CHANGED
@@ -4,7 +4,9 @@
4
4
  // parsing, no process.exit, no prints (accounts.js module-load resolution is
5
5
  // the one inherited, pre-existing exception on the import chain).
6
6
  import { GENERATED_SERVICES } from './tools/generated/index.js';
7
- import { GENERATED_GATES, SERVICES } from './services.js';
7
+ import { GENERATED_GATES, SERVICES, unknownToolMessage } from './services.js';
8
+ import { argNormalizationEnabled } from './arg-normalize.js';
9
+ import { unknownArgMode } from './arg-strict.js';
8
10
  import { ToolRegistry } from './registry.js';
9
11
  import { registerDiscoverTools } from './discover.js';
10
12
  import { registerEscapeTools } from './tools/google-api.js';
@@ -82,3 +84,26 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
82
84
  registerAccountWizardTools(registry, server);
83
85
  return registry;
84
86
  }
87
+ /** The per-request hooks bound to one registry: argument shapes, unknown-
88
+ * argument screening and the account a validation envelope names. Both
89
+ * transports and every resolved server target build them here, so a mistyped
90
+ * argument behaves the same wherever it arrives. */
91
+ export function requestHooksFor(registry, ctx, metrics = null, env = process.env) {
92
+ const mode = unknownArgMode(env);
93
+ return {
94
+ argShapeFor: argNormalizationEnabled(env) ? (tool) => registry.argShape(tool) : undefined,
95
+ strictArgs: mode === 'off'
96
+ ? undefined
97
+ : {
98
+ mode,
99
+ declaredFor: (tool) => registry.declaredKeys(tool),
100
+ siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
101
+ unknownTool: (name) => unknownToolMessage(registry, name),
102
+ onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
103
+ },
104
+ validationEnvelope: {
105
+ isKnownTool: (name) => registry.hasTool(name),
106
+ defaultAccount: () => ctx.accounts.defaultAccount,
107
+ },
108
+ };
109
+ }
@@ -15,6 +15,16 @@ export type AuthOutcome = {
15
15
  export type Authenticator = (req: IncomingMessage) => AuthOutcome | Promise<AuthOutcome>;
16
16
  /** A mounted extra route (the AS endpoints, B13). Return true if it wrote a response. */
17
17
  export type RouteHandler = (req: IncomingMessage, res: ServerResponse, url: URL) => boolean | Promise<boolean>;
18
+ /** What a verified subject resolves to: ITS server plus the per-request hooks
19
+ * bound to that server's registry (tool shapes, declared keys, the default
20
+ * account a validation envelope names). The metrics observers (metricsTap,
21
+ * onArgRename) stay host-wide and must not be bound to any one registry. */
22
+ export interface ServerTarget {
23
+ server: McpServer;
24
+ argShapeFor?: (tool: string) => ArgShape | undefined;
25
+ strictArgs?: StrictArgOptions;
26
+ validationEnvelope?: ValidationEnvelopeOptions;
27
+ }
18
28
  export interface HttpHostOptions {
19
29
  /** The ONE McpServer + registry built at boot (P1 / BV gap #4: never per request). */
20
30
  server: McpServer;
@@ -25,14 +35,13 @@ export interface HttpHostOptions {
25
35
  /** Tenant-resolution seam: map the verified subject to ITS server. Absent
26
36
  * (free core) = every request dispatches to the one boot `server`. Return
27
37
  * null for an unknown subject -> 403 tenant_not_found. Each distinct
28
- * resolved server gets its own serialize key. */
38
+ * resolved server gets its own dispatch lane. With a resolver, the
39
+ * per-request hooks come from the resolved target ONLY: the host-level
40
+ * argShapeFor / strictArgs / validationEnvelope describe the boot server's
41
+ * registry, never another subject's. */
29
42
  resolveServer?: (claims: {
30
43
  sub: string;
31
- }) => Promise<{
32
- server: McpServer;
33
- } | null> | {
34
- server: McpServer;
35
- } | null;
44
+ }) => Promise<ServerTarget | null> | ServerTarget | null;
36
45
  /** Extra routes keyed by exact pathname (AS endpoints mount here in B13). */
37
46
  routes?: Record<string, RouteHandler>;
38
47
  log?: (line: string) => void;
@@ -43,12 +43,12 @@ export function jsonRpcMethod(body) {
43
43
  export class HttpTransportHost {
44
44
  opts;
45
45
  httpServer;
46
- // Serialize the connect→dispatch critical section PER KEY: a McpServer
46
+ // Serialize the connect→dispatch critical section PER SERVER: a McpServer
47
47
  // captures its transport per request (protocol.js), but the gap between
48
48
  // connect() and the dispatch capturing it would still race under true
49
49
  // concurrency, so all traffic to ONE server runs under one mutex. The free
50
- // core uses a single key (one boot server); with resolveServer each subject
51
- // gets its own server and therefore its own independent lock lane.
50
+ // core has one lane (the boot server); with resolveServer each resolved
51
+ // server gets its own, however subjects map onto servers.
52
52
  locks = new Map();
53
53
  constructor(opts) {
54
54
  this.opts = opts;
@@ -56,7 +56,15 @@ export class HttpTransportHost {
56
56
  serializeFor(key, fn) {
57
57
  const prev = this.locks.get(key) ?? Promise.resolve();
58
58
  const run = prev.then(fn, fn);
59
- this.locks.set(key, run.then(() => undefined, () => undefined));
59
+ const tail = run.then(() => undefined, () => undefined);
60
+ this.locks.set(key, tail);
61
+ // An idle lane drops out, so the map holds only in-flight subjects rather
62
+ // than every subject ever seen. A request that chained on this tail has
63
+ // already replaced it, so the identity check never frees a live lane.
64
+ void tail.then(() => {
65
+ if (this.locks.get(key) === tail)
66
+ this.locks.delete(key);
67
+ });
60
68
  return run;
61
69
  }
62
70
  log(line) {
@@ -154,7 +162,12 @@ export class HttpTransportHost {
154
162
  const sub = auth.sub ?? 'owner';
155
163
  // Tenant resolution (the seam EE fills): map the verified subject to ITS
156
164
  // server. Absent resolver = the one boot server for every subject.
157
- let target = { server: this.opts.server };
165
+ let target = {
166
+ server: this.opts.server,
167
+ argShapeFor: this.opts.argShapeFor,
168
+ strictArgs: this.opts.strictArgs,
169
+ validationEnvelope: this.opts.validationEnvelope,
170
+ };
158
171
  if (this.opts.resolveServer) {
159
172
  try {
160
173
  target = await this.opts.resolveServer({ sub });
@@ -204,12 +217,11 @@ export class HttpTransportHost {
204
217
  // releases. (A shared initialized-state persists across stateless requests;
205
218
  // benign for the single-owner design.)
206
219
  const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
207
- // The lock lane must match the SERVER identity, not the subject: without a
208
- // resolver every subject shares the one boot server, so keying on sub
209
- // would let two subjects race one server through the connect gap.
210
- const serializeKey = this.opts.resolveServer ? sub : '__single__';
211
- const mcpServer = target.server;
212
- await this.serializeFor(serializeKey, async () => {
220
+ // The lock lane is the SERVER identity, never the subject: two subjects
221
+ // reaching one server (the boot server, or a resolver mapping several
222
+ // subjects to one) would otherwise race it through the connect gap.
223
+ const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
224
+ await this.serializeFor(mcpServer, async () => {
213
225
  const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
214
226
  let timer;
215
227
  const timedOut = new Promise((resolve) => {
@@ -218,10 +230,10 @@ export class HttpTransportHost {
218
230
  });
219
231
  // Same composition as the stdio leg: the envelope rewrite is innermost,
220
232
  // so the tap above it still classifies the original validation prose.
221
- const enveloped = withValidationEnvelope(transport, this.opts.validationEnvelope ?? {});
233
+ const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
222
234
  const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
223
- await mcpServer.connect(this.opts.argShapeFor || this.opts.strictArgs
224
- ? withArgNormalization(tapped, this.opts.argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, this.opts.strictArgs)
235
+ await mcpServer.connect(argShapeFor || strictArgs
236
+ ? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
225
237
  : tapped);
226
238
  // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
227
239
  // race, an orphaned handler settling later must not surface as an unhandled
package/dist/index.js CHANGED
@@ -1,42 +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
5
  import { accountsAssertRequired, assertNoEnvAccountsMode, assertNoEnvOptionalScopesMode, isMultiTenantBoot, mtBootGates } from './boot-gates.js';
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import path from 'node:path';
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
10
10
  import { McpServer } from "@modelcontextprotocol/server";
11
- import { unknownToolMessage } from './services.js';
12
11
  import { resolveDiscoveryMode } from './registry.js';
13
12
  import { isAllowed, describePolicy } from './write-control.js';
14
13
  import { buildIdentityContext } from './identity.js';
15
- import { buildRegistry } from './compose.js';
14
+ import { buildRegistry, requestHooksFor } from './compose.js';
16
15
  import { registerSetupPrompt } from './setup-prompt.js';
17
16
  import { applyNetTuning } from './net-tuning.js';
18
- import { argNormalizationEnabled, withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
19
- import { unknownArgMode } from './arg-strict.js';
17
+ import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
20
18
  import { envValueSource } from './env-load.js';
21
19
  import { loadConfigFile } from './config-file.js';
22
20
  import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
23
21
  applyNetTuning();
24
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
23
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
26
- /** Unknown-argument screening options, or undefined when the feature is off.
27
- * Shared by both transports so a mistyped argument behaves identically. */
28
- function strictArgOptions(registry, metrics) {
29
- const mode = unknownArgMode();
30
- if (mode === 'off')
31
- return undefined;
32
- return {
33
- mode,
34
- declaredFor: (tool) => registry.declaredKeys(tool),
35
- siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
36
- unknownTool: (name) => unknownToolMessage(registry, name),
37
- onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
38
- };
39
- }
40
24
  async function main() {
41
25
  if (process.argv.includes('auth')) {
42
26
  const { runAuthFlow } = await import('./auth.js');
@@ -208,30 +192,28 @@ async function main() {
208
192
  if (wantStdio) {
209
193
  const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
210
194
  const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
211
- 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);
212
197
  registry.installListHandler();
213
198
  registerSetupPrompt(server);
199
+ const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
214
200
  // Outbound runs outermost-first, so the composition is deliberate: the
215
201
  // envelope rewrite is INNERMOST (last to touch the frame), the tap sits
216
202
  // above it (classifying the ORIGINAL validation prose), arg normalization
217
203
  // outermost. The tap only counts outbound JSON-RPC error frames (no
218
204
  // handler ran) plus id->tool names.
219
- let transport = withValidationEnvelope(new StdioServerTransport(), {
220
- isKnownTool: (n) => registry.hasTool(n),
221
- defaultAccount: () => getAccountSet().defaultAccount,
222
- });
205
+ let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
223
206
  if (stdioMetrics) {
224
207
  const { tapUsageMetrics } = await import('./metrics-tap.js');
225
208
  transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
226
209
  }
227
- const strictStdio = strictArgOptions(registry, stdioMetrics);
228
- await server.connect(argNormalizationEnabled() || strictStdio
210
+ await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
229
211
  ? withArgNormalization(transport,
230
212
  // Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
231
213
  // shape unconditionally made stdio rename keys while
232
214
  // GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
233
215
  // and failed on HTTP once screening rejects.
234
- 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)
235
217
  : transport);
236
218
  }
237
219
  if (wantHttp) {
@@ -259,11 +241,12 @@ async function main() {
259
241
  }
260
242
  const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
261
243
  const httpMetrics = initMetricsFor('http', 'curated');
262
- 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);
263
246
  registry.installListHandler();
264
247
  registerSetupPrompt(httpServer);
265
248
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
266
- const { buildAuthServer } = await import('./oauth-as.js');
249
+ const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
267
250
  const { jwtSecretFrom } = await import('./mcp-token.js');
268
251
  const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
269
252
  const { OAuth2Client } = await import('googleapis-common');
@@ -272,20 +255,6 @@ async function main() {
272
255
  const { configDir } = await import('./config-file.js');
273
256
  const { getAccountSet } = await import('./accounts.js');
274
257
  const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
275
- // Only trust the id_token email when Google marks it verified (#8).
276
- const verifiedEmailFromIdToken = (idToken) => {
277
- if (!idToken)
278
- return undefined;
279
- try {
280
- const [, payload] = idToken.split('.');
281
- const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
282
- const verified = claims.email_verified === true || claims.email_verified === 'true';
283
- return verified ? claims.email : undefined;
284
- }
285
- catch {
286
- return undefined;
287
- }
288
- };
289
258
  const authServer = buildAuthServer({
290
259
  base: httpCfg.publicUrl,
291
260
  resourceUri: httpCfg.resourceUri,
@@ -326,8 +295,7 @@ async function main() {
326
295
  const { setHttpReauthBase } = await import('./reauth-hint.js');
327
296
  setHttpReauthBase(httpCfg.publicUrl);
328
297
  // Wizard consent over HTTP: hand out the clientless AS link instead of
329
- // binding a loopback listener (single-owner flow; a tenancy host installs
330
- // its own signed-mint context here).
298
+ // binding a loopback listener.
331
299
  const { setWizardHttpConsent } = await import('./tools/account-wizard.js');
332
300
  setWizardHttpConsent({
333
301
  mintConsentUrl: (alias) => `${httpCfg.publicUrl}/authorize?flow=alias_reauth&alias=${encodeURIComponent(alias)}`,
@@ -345,14 +313,9 @@ async function main() {
345
313
  authenticate: authServer.authenticate,
346
314
  routes: authServer.routes,
347
315
  log: (l) => process.stderr.write(`[http] ${l}\n`),
348
- argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
316
+ ...requestHooksFor(registry, httpCtx, httpMetrics),
349
317
  metricsTap: httpTap,
350
- validationEnvelope: {
351
- isKnownTool: (n) => registry.hasTool(n),
352
- defaultAccount: () => getAccountSet().defaultAccount,
353
- },
354
318
  onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
355
- strictArgs: strictArgOptions(registry, httpMetrics),
356
319
  });
357
320
  await host.start();
358
321
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
@@ -32,6 +32,9 @@ export interface StatePayload {
32
32
  tenantId?: string;
33
33
  /** alias_add only: scope bundles chosen when the link was minted. */
34
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;
35
38
  }
36
39
  export interface CodePayload {
37
40
  redirect_uri: string;
@@ -1,5 +1,5 @@
1
1
  import { CONFIG_VERSION, configFilePath, loadConfigFile, mutateConfigFile } from './config-file.js';
2
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
2
+ import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
3
3
  function parseCsv(value) {
4
4
  return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
5
5
  }
@@ -47,7 +47,7 @@ export function runMigrateConfig(env = process.env) {
47
47
  process.exitCode = 1;
48
48
  return;
49
49
  }
50
- if (!(bundle in BUNDLE_CATALOG)) {
50
+ if (!isKnownBundle(bundle)) {
51
51
  const hint = closestBundle(bundle);
52
52
  console.error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}. Nothing written.`);
53
53
  process.exitCode = 1;
@@ -22,6 +22,27 @@ export interface AuthServerConfig {
22
22
  * cross-tenant re-auth vector. Server-minted signed flows are unaffected. */
23
23
  legacyAliasReauth?: boolean;
24
24
  }
25
+ /** What /callback hands the alias_add binder once Google returned a verified
26
+ * identity for a server-minted link. */
27
+ export interface TenantAliasBind {
28
+ tenantId: string;
29
+ alias: string;
30
+ email: string;
31
+ bundles?: string[];
32
+ nonce?: string;
33
+ tokens: Record<string, unknown>;
34
+ }
35
+ /** A binder's deliberate refusal (policy, not failure): /callback answers 403
36
+ * with this slug and message. The message reaches an unauthenticated browser. */
37
+ export interface AliasBindRefusal {
38
+ refused: {
39
+ slug: string;
40
+ message: string;
41
+ };
42
+ }
43
+ /** Only trust the id_token email when Google marks it verified (#8). The token
44
+ * comes straight from Google's token endpoint, so its claims are read unsigned. */
45
+ export declare function verifiedEmailFromIdToken(idToken?: string | null): string | undefined;
25
46
  export interface AuthServerDeps {
26
47
  fetchCimd?: (clientId: string) => Promise<Record<string, unknown>>;
27
48
  /** Exchange a Google auth code at `${base}/callback` for tokens (+ owner email). */
@@ -34,6 +55,7 @@ export interface AuthServerDeps {
34
55
  flow: StatePayload['flow'];
35
56
  alias?: string;
36
57
  state: string;
58
+ bundles?: string[];
37
59
  }) => string;
38
60
  writeToken?: (alias: string, tokens: Record<string, unknown>) => void;
39
61
  registeredClients?: Map<string, {
@@ -52,16 +74,11 @@ export interface AuthServerDeps {
52
74
  resolveSubject?: (email: string) => {
53
75
  sub: string;
54
76
  } | null;
55
- /** alias_add completion: persist the config row AND the tokens for the new
56
- * alias under its tenant in ONE call, atomically — /callback invokes it only
57
- * after a successful Google exchange, so no pre-write orphan can exist. */
58
- bindTenantAlias?: (bind: {
59
- tenantId: string;
60
- alias: string;
61
- email: string;
62
- bundles?: string[];
63
- tokens: Record<string, unknown>;
64
- }) => void;
77
+ /** alias_add completion: persist the new alias's config row and tokens under
78
+ * its tenant. /callback awaits it only after a successful Google exchange.
79
+ * A returned refusal answers 403 with its slug; a throw or rejection answers
80
+ * 500 E_ALIAS_ADD_FAILED. */
81
+ bindTenantAlias?: (bind: TenantAliasBind) => void | AliasBindRefusal | Promise<void | AliasBindRefusal>;
65
82
  }
66
83
  export interface AuthServer {
67
84
  routes: Record<string, RouteHandler>;
@@ -69,12 +86,13 @@ export interface AuthServer {
69
86
  /** Mint a signed, single-use, TTL-bound clientless flow URL
70
87
  * (`${base}/authorize?flow=...&state=<signed>`). The tenantId/bundles ride
71
88
  * INSIDE the signed state, so nothing at /authorize or /callback trusts a
72
- * caller-supplied identity. Inert unless something calls it (EE wizard). */
89
+ * caller-supplied identity. Inert unless something calls it. */
73
90
  mintFlowState: (opts: {
74
91
  flow: 'alias_add' | 'alias_reauth';
75
92
  alias: string;
76
93
  tenantId?: string;
77
94
  bundles?: string[];
95
+ nonce?: string;
78
96
  }) => Promise<string>;
79
97
  }
80
98
  /** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
package/dist/oauth-as.js CHANGED
@@ -17,6 +17,21 @@ const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
17
17
  export const DCR_MAX_CLIENTS = 1000;
18
18
  export const DCR_MAX_REDIRECT_URIS = 10;
19
19
  const DCR_MAX_URI_LEN = 2048;
20
+ /** Only trust the id_token email when Google marks it verified (#8). The token
21
+ * comes straight from Google's token endpoint, so its claims are read unsigned. */
22
+ export function verifiedEmailFromIdToken(idToken) {
23
+ if (!idToken)
24
+ return undefined;
25
+ try {
26
+ const [, payload] = idToken.split('.');
27
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
28
+ const verified = claims.email_verified === true || claims.email_verified === 'true';
29
+ return verified ? claims.email : undefined;
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ }
20
35
  // --- helpers ----------------------------------------------------------------
21
36
  function json(res, status, body, extraHeaders = {}) {
22
37
  res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
@@ -203,7 +218,7 @@ export function buildAuthServer(config, deps = {}) {
203
218
  async function toGoogle(res, sp) {
204
219
  const state = await signState(sp, base, secret, nowSec());
205
220
  const authUrl = deps.buildGoogleAuthUrl
206
- ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
221
+ ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
207
222
  : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
208
223
  return redirect(res, authUrl);
209
224
  }
@@ -356,16 +371,26 @@ export function buildAuthServer(config, deps = {}) {
356
371
  const boundEmail = (exchanged.email ?? '').toLowerCase();
357
372
  if (!boundEmail)
358
373
  return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
374
+ let refusal;
359
375
  try {
360
- deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, tokens: exchanged.tokens });
376
+ const out = await deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, nonce: st.nonce, tokens: exchanged.tokens });
377
+ refusal = out?.refused;
361
378
  }
362
379
  catch (e) {
363
- log(`alias_add bind failed for "${st.alias}": ${e.message}`);
380
+ // Any rejection reason, even none, must still reach this answer.
381
+ log(`alias_add bind failed for "${st.alias}": ${e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection'}`);
364
382
  return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
365
383
  }
384
+ if (refusal) {
385
+ // The slug lands in a text/plain body; keep it a bare identifier.
386
+ const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
387
+ const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
388
+ log(`alias_add refused for "${st.alias}": ${slug}`);
389
+ return errorPage(res, 403, slug, message);
390
+ }
366
391
  log(`callback ok flow=alias_add alias=${st.alias}`);
367
392
  res.writeHead(200, { 'Content-Type': 'text/html' });
368
- res.end(`<!doctype html><meta charset=utf-8><p>Connected "${st.alias}". You can close this window and retry your request.</p>`);
393
+ res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
369
394
  return true;
370
395
  }
371
396
  if (st.flow === 'alias_reauth') {
@@ -385,7 +410,7 @@ export function buildAuthServer(config, deps = {}) {
385
410
  deps.writeToken?.(st.alias, exchanged.tokens);
386
411
  log(`callback ok flow=alias_reauth alias=${st.alias}`);
387
412
  res.writeHead(200, { 'Content-Type': 'text/html' });
388
- res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${st.alias}". You can close this window.</p>`);
413
+ res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>`);
389
414
  return true;
390
415
  }
391
416
  // owner_gate: resolve the verified email to its subject (default = the
@@ -503,8 +528,8 @@ export function buildAuthServer(config, deps = {}) {
503
528
  return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
504
529
  }
505
530
  };
506
- const mintFlowState = async ({ flow, alias, tenantId, bundles }) => {
507
- const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles }, base, secret, nowSec());
531
+ const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
532
+ const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, secret, nowSec());
508
533
  return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
509
534
  };
510
535
  return { routes, authenticate, mintFlowState };
@@ -14,6 +14,9 @@ export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
14
14
  /** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
15
15
  * future rename is a deprecation warning, never a breaking config change. */
16
16
  export declare const BUNDLE_ALIASES: Record<string, string>;
17
+ /** Bundle names are user input: an Object.prototype member ("constructor",
18
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
19
+ export declare function isKnownBundle(name: string): boolean;
17
20
  export declare function resolveBundleAliases(bundles: string[]): string[];
18
21
  /** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
19
22
  export declare function closestBundle(name: string): string | undefined;
@@ -160,9 +160,14 @@ export const BUNDLE_CATALOG = {
160
160
  * future rename is a deprecation warning, never a breaking config change. */
161
161
  export const BUNDLE_ALIASES = {};
162
162
  const warnedAliases = new Set();
163
+ /** Bundle names are user input: an Object.prototype member ("constructor",
164
+ * "toString") passes a plain `in` or index lookup, so membership is own-key only. */
165
+ export function isKnownBundle(name) {
166
+ return Object.hasOwn(BUNDLE_CATALOG, name);
167
+ }
163
168
  export function resolveBundleAliases(bundles) {
164
169
  return bundles.map((name) => {
165
- const target = BUNDLE_ALIASES[name];
170
+ const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
166
171
  if (!target)
167
172
  return name;
168
173
  if (!warnedAliases.has(name)) {
@@ -57,7 +57,7 @@ export declare function scopeGrantDiff(requested: string[], grantedScope: string
57
57
  * present, runConsent NEVER binds a server-side loopback listener — the
58
58
  * user's browser cannot reach the daemon's loopback — and instead hands out
59
59
  * a clientless AS consent URL. The injector decides the flow (single-owner =
60
- * the legacy alias_reauth link; a tenancy host mints a signed alias_add URL). */
60
+ * the legacy alias_reauth link). */
61
61
  export interface WizardHttpConsent {
62
62
  mintConsentUrl: (alias: string) => Promise<string> | string;
63
63
  }
@@ -3,7 +3,7 @@ import { getAccountSet, invalidateAccountSet } from '../accounts.js';
3
3
  import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
4
4
  import { writeToken } from '../token-store.js';
5
5
  import { resolveScopesForAccount } from '../auth.js';
6
- import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
6
+ import { BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from '../scope-catalog.js';
7
7
  import { openUrl } from '../open-url.js';
8
8
  import { coerceBoolean } from './_coerce.js';
9
9
  import { safeMessage, stringifyEnvelope } from './_errors.js';
@@ -58,7 +58,7 @@ export function validateAddForm(input, existingAliases) {
58
58
  if (b === 'admin') {
59
59
  return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: '"admin" is not a scope bundle.', hint: 'Use the admin checkbox instead, or pass "admin": true.', alias };
60
60
  }
61
- if (!(b in BUNDLE_CATALOG)) {
61
+ if (!isKnownBundle(b)) {
62
62
  const closest = closestBundle(b);
63
63
  const known = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ');
64
64
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.54",
3
+ "version": "6.0.0-alpha.56",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",
@@ -28,6 +28,8 @@
28
28
  "./tenant-purge": "./dist/tenant-purge.js",
29
29
  "./doctor": "./dist/doctor.js",
30
30
  "./write-control": "./dist/write-control.js",
31
+ "./scope-catalog": "./dist/scope-catalog.js",
32
+ "./auth": "./dist/auth.js",
31
33
  "./package.json": "./package.json"
32
34
  },
33
35
  "files": [