mcp-google-multi 6.0.0-alpha.55 → 6.0.0-alpha.57

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 (58) hide show
  1. package/dist/accounts.d.ts +5 -0
  2. package/dist/accounts.js +26 -7
  3. package/dist/arg-normalize.js +3 -3
  4. package/dist/arg-strict.d.ts +7 -0
  5. package/dist/arg-strict.js +34 -4
  6. package/dist/auth.js +2 -2
  7. package/dist/compose.js +2 -2
  8. package/dist/discovery-client.d.ts +3 -0
  9. package/dist/discovery-client.js +6 -1
  10. package/dist/fanout.d.ts +9 -1
  11. package/dist/fanout.js +33 -10
  12. package/dist/http-transport.js +7 -0
  13. package/dist/index.js +4 -31
  14. package/dist/mcp-token.d.ts +3 -0
  15. package/dist/migrate-config.js +2 -2
  16. package/dist/oauth-as.d.ts +32 -14
  17. package/dist/oauth-as.js +40 -7
  18. package/dist/oauth-consent.d.ts +10 -0
  19. package/dist/oauth-consent.js +16 -0
  20. package/dist/registry.d.ts +2 -0
  21. package/dist/registry.js +23 -12
  22. package/dist/scope-catalog.d.ts +11 -0
  23. package/dist/scope-catalog.js +23 -6
  24. package/dist/services.d.ts +7 -7
  25. package/dist/services.js +36 -7
  26. package/dist/tools/account-wizard.d.ts +2 -2
  27. package/dist/tools/account-wizard.js +26 -13
  28. package/dist/tools/generated/_shared.d.ts +1 -1
  29. package/dist/tools/generated/_shared.js +6 -5
  30. package/dist/tools/generated/admin.js +132 -132
  31. package/dist/tools/generated/analytics.js +62 -62
  32. package/dist/tools/generated/appsmarket.js +2 -2
  33. package/dist/tools/generated/calendar.js +27 -27
  34. package/dist/tools/generated/chat.js +47 -47
  35. package/dist/tools/generated/classroom.js +104 -104
  36. package/dist/tools/generated/cloudidentity.js +66 -66
  37. package/dist/tools/generated/cloudsearch.js +49 -49
  38. package/dist/tools/generated/contacts.js +14 -14
  39. package/dist/tools/generated/drive.js +35 -35
  40. package/dist/tools/generated/driveactivity.js +1 -1
  41. package/dist/tools/generated/drivelabels.js +26 -26
  42. package/dist/tools/generated/forms.js +3 -3
  43. package/dist/tools/generated/gmail.js +58 -58
  44. package/dist/tools/generated/groupsmigration.js +1 -1
  45. package/dist/tools/generated/groupssettings.js +3 -3
  46. package/dist/tools/generated/keep.js +7 -7
  47. package/dist/tools/generated/licensing.js +7 -7
  48. package/dist/tools/generated/meet.js +13 -13
  49. package/dist/tools/generated/postmaster.js +4 -4
  50. package/dist/tools/generated/reseller.js +17 -17
  51. package/dist/tools/generated/script.js +16 -16
  52. package/dist/tools/generated/searchconsole.js +1 -1
  53. package/dist/tools/generated/sheets.js +7 -7
  54. package/dist/tools/generated/tasks.js +2 -2
  55. package/dist/tools/generated/vault.js +33 -33
  56. package/dist/tools/generated/workspaceevents.js +15 -15
  57. package/dist/tools/google-api.js +16 -7
  58. package/package.json +3 -1
@@ -56,6 +56,11 @@ export declare const accountAliasSchema: z.ZodType<string>;
56
56
  * permissive (same empty-safe rule as above); dispatch still resolves the
57
57
  * alias against the live registry. */
58
58
  export declare function accountArgLive(aliases: () => readonly string[]): z.ZodType<string>;
59
+ /** The live membership check behind accountArgLive and the fan-out field;
60
+ * `selectors` also admits "*". An empty set stays permissive. */
61
+ export declare function liveAccountCheck(aliases: () => readonly string[], selectors: boolean): z.core.$ZodCheck<string>;
62
+ /** True for a field built on liveAccountCheck, optional or not. */
63
+ export declare function isLiveAccountField(field: unknown): boolean;
59
64
  /**
60
65
  * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
61
66
  * bootstraps via env / `migrate-config` / `account import` / `auth` first. The
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
@@ -95,7 +95,7 @@ opts = {}) {
95
95
  if (bundle === 'admin') {
96
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.');
97
97
  }
98
- if (!(bundle in BUNDLE_CATALOG)) {
98
+ if (!isKnownBundle(bundle)) {
99
99
  const hint = closestBundle(bundle);
100
100
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
101
101
  }
@@ -152,7 +152,7 @@ opts = {}) {
152
152
  for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
153
153
  const bundles = resolveBundleAliases(profile.bundles);
154
154
  for (const bundle of bundles) {
155
- if (!(bundle in BUNDLE_CATALOG)) {
155
+ if (!isKnownBundle(bundle)) {
156
156
  const hint = closestBundle(bundle);
157
157
  fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
158
158
  }
@@ -287,6 +287,8 @@ export const ACCOUNTS = current.aliases;
287
287
  * enum actually accepts, and naming a runtime-added alias as valid while
288
288
  * rejecting it would be a lie. */
289
289
  export function unknownAliasMessage(aliases, selectors = false) {
290
+ if (aliases.length === 0)
291
+ return 'No Google account is configured yet. Add one with account_add.';
290
292
  const all = selectors ? '; "*" for all accounts' : '';
291
293
  const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
292
294
  return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
@@ -312,12 +314,29 @@ export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
312
314
  * permissive (same empty-safe rule as above); dispatch still resolves the
313
315
  * alias against the live registry. */
314
316
  export function accountArgLive(aliases) {
315
- return z.string().superRefine((value, ctx) => {
317
+ return z.string().check(liveAccountCheck(aliases, false));
318
+ }
319
+ // The check instance survives the clones .describe() and .optional() make,
320
+ // so it is what marks a field as an account selector for the registry.
321
+ const LIVE_ACCOUNT_CHECKS = new WeakSet();
322
+ /** The live membership check behind accountArgLive and the fan-out field;
323
+ * `selectors` also admits "*". An empty set stays permissive. */
324
+ export function liveAccountCheck(aliases, selectors) {
325
+ const check = z.superRefine((value, ctx) => {
316
326
  const current = aliases();
317
- if (current.length > 0 && !current.includes(value)) {
318
- ctx.addIssue({ code: 'custom', message: unknownAliasMessage(current) });
319
- }
327
+ if (current.length === 0 || current.includes(value) || (selectors && value === '*'))
328
+ return;
329
+ ctx.addIssue({ code: 'custom', message: unknownAliasMessage(current, selectors) });
320
330
  });
331
+ LIVE_ACCOUNT_CHECKS.add(check);
332
+ return check;
333
+ }
334
+ /** True for a field built on liveAccountCheck, optional or not. */
335
+ export function isLiveAccountField(field) {
336
+ let def = field?._zod?.def;
337
+ if (def?.type === 'optional')
338
+ def = def.innerType?._zod?.def;
339
+ return def?.checks?.some((c) => LIVE_ACCOUNT_CHECKS.has(c)) ?? false;
321
340
  }
322
341
  /**
323
342
  * BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
@@ -1,4 +1,4 @@
1
- import { screenArguments, unknownArgEnvelope, } from './arg-strict.js';
1
+ import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
2
2
  import { sliceClean } from './trim.js';
3
3
  // The SDK synthesizes input-validation failures as isError RESULTS, skipping
4
4
  // the handler entirely: no handler of ours runs, so no envelope exists and the
@@ -116,7 +116,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
116
116
  return { action: 'forward', msg };
117
117
  const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
118
118
  // Key names only; argument VALUES never reach the log.
119
- log(`[args] ${tool}: undeclared ${all.join(', ')}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
119
+ log(`[args] ${tool}: undeclared ${echoNames(all)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
120
120
  try {
121
121
  // Only ever a DECLARED key or the literal placeholder, so the metrics
122
122
  // closed-vocabulary rule holds: the caller's key is never persisted.
@@ -132,7 +132,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
132
132
  const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
133
133
  ? []
134
134
  : (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
135
- const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
135
+ const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? echoName(account) : undefined, siblings);
136
136
  return {
137
137
  action: 'reject',
138
138
  response: {
@@ -14,6 +14,13 @@ export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
14
14
  * tools: no declared key starts with `_` or contains `/`, so neither rule can
15
15
  * shadow a real parameter. */
16
16
  export declare function isExemptKey(key: string): boolean;
17
+ /** A call can carry thousands of unknown keys, or one of megabytes: only the
18
+ * first few earn a did-you-mean, and the log line and the envelope echo a
19
+ * bounded prefix of each name. The reject decision itself is never capped. */
20
+ export declare const MAX_SUGGESTED_KEYS = 8;
21
+ export declare function echoName(name: string): string;
22
+ /** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
23
+ export declare function echoNames(names: readonly string[], quote?: boolean): string;
17
24
  /**
18
25
  * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
19
26
  * 0 same key modulo case and separators
@@ -9,7 +9,8 @@
9
9
  // and never touches a VALUE: it only decides that a key is undeclared, and
10
10
  // suggests what the caller probably meant. Rewriting would risk sending a real
11
11
  // value to Google on a guess, which is strictly worse than refusing.
12
- import { editDistance } from './scope-catalog.js';
12
+ import { editDistance, SUGGEST_MAX_INPUT } from './scope-catalog.js';
13
+ import { sliceClean } from './trim.js';
13
14
  /**
14
15
  * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
15
16
  * back to `warn`, not to the default: an operator who misspells the setting
@@ -59,6 +60,20 @@ export function isExemptKey(key) {
59
60
  * alone; they match far too much to be useful. */
60
61
  const GENERIC = new Set(['id', 'name', 'title', 'body', 'parent', 'text', 'content', 'type', 'value', 'key', 'data']);
61
62
  const flatten = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
63
+ /** A call can carry thousands of unknown keys, or one of megabytes: only the
64
+ * first few earn a did-you-mean, and the log line and the envelope echo a
65
+ * bounded prefix of each name. The reject decision itself is never capped. */
66
+ export const MAX_SUGGESTED_KEYS = 8;
67
+ const MAX_ECHO_CHARS = 64;
68
+ export function echoName(name) {
69
+ return name.length > MAX_ECHO_CHARS ? `${sliceClean(name, MAX_ECHO_CHARS)}...` : name;
70
+ }
71
+ /** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
72
+ export function echoNames(names, quote = false) {
73
+ const shown = names.slice(0, MAX_SUGGESTED_KEYS).map((n) => (quote ? `"${echoName(n)}"` : echoName(n)));
74
+ const rest = names.length - shown.length;
75
+ return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ');
76
+ }
62
77
  function tokens(s) {
63
78
  return s
64
79
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
@@ -76,6 +91,8 @@ function tokens(s) {
76
91
  * distance 6, well past any sane threshold, which is why tier 1 exists.
77
92
  */
78
93
  export function suggestKeys(unknown, declared, limit = 2) {
94
+ if (unknown.length > SUGGEST_MAX_INPUT)
95
+ return [];
79
96
  const uFlat = flatten(unknown);
80
97
  const uTok = tokens(unknown);
81
98
  const tiers = [[], [], [], []];
@@ -94,8 +111,12 @@ export function suggestKeys(unknown, declared, limit = 2) {
94
111
  tiers[2].push({ key, rank: Math.abs(kFlat.length - uFlat.length) });
95
112
  continue;
96
113
  }
114
+ const maxDist = Math.max(1, Math.floor(uFlat.length / 4));
115
+ // The distance is at least the length gap: skip the DP when that alone fails.
116
+ if (Math.abs(uFlat.length - kFlat.length) > maxDist)
117
+ continue;
97
118
  const d = editDistance(uFlat, kFlat);
98
- if (d <= Math.max(1, Math.floor(uFlat.length / 4)))
119
+ if (d <= maxDist)
99
120
  tiers[3].push({ key, rank: d });
100
121
  }
101
122
  const tier = tiers.find((t) => t.length > 0);
@@ -117,10 +138,19 @@ export function screenArguments(tool, args, declared) {
117
138
  if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
118
139
  return out;
119
140
  const declaredSet = new Set(declared);
141
+ // Tier 0 (same key modulo case and separators) is a map lookup, so every key
142
+ // gets it and a redundant twin is recognised past the ranking budget too.
143
+ // A near-miss duplicate (a typo sent next to its declared key) is recognised
144
+ // only within the budget: a call needs 9 of them before one is refused.
145
+ const byFlat = new Map();
146
+ for (const k of declared)
147
+ byFlat.set(flatten(k), [...(byFlat.get(flatten(k)) ?? []), k]);
148
+ let ranked = 0;
120
149
  for (const key of Object.keys(args)) {
121
150
  if (declaredSet.has(key) || isExemptKey(key))
122
151
  continue;
123
- const suggestions = suggestKeys(key, declared);
152
+ const twins = key.length <= SUGGEST_MAX_INPUT ? byFlat.get(flatten(key)) : undefined;
153
+ const suggestions = twins ? twins.slice(0, 2) : ranked++ < MAX_SUGGESTED_KEYS ? suggestKeys(key, declared) : [];
124
154
  // The caller sent both spellings, so the declared one already won and the
125
155
  // outcome is what it has always been. Never fail a call that works today.
126
156
  if (suggestions.some((s) => s in args)) {
@@ -133,7 +163,7 @@ export function screenArguments(tool, args, declared) {
133
163
  }
134
164
  /** The agent-facing envelope. Values are never included, only key names. */
135
165
  export function unknownArgEnvelope(tool, unknown, declared, account, siblings = []) {
136
- const names = unknown.map((u) => `"${u.sent}"`).join(', ');
166
+ const names = echoNames(unknown.map((u) => u.sent), true);
137
167
  const accepts = `This tool accepts: ${declared.join(', ')}.`;
138
168
  const suggested = unknown.flatMap((u) => u.suggestions);
139
169
  let hint;
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.js CHANGED
@@ -4,7 +4,7 @@
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, unknownToolMessage } from './services.js';
7
+ import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
8
8
  import { argNormalizationEnabled } from './arg-normalize.js';
9
9
  import { unknownArgMode } from './arg-strict.js';
10
10
  import { ToolRegistry } from './registry.js';
@@ -81,7 +81,7 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
81
81
  // The wizard edits the owner's config.json and token store; any other
82
82
  // context has neither.
83
83
  if (owner)
84
- registerAccountWizardTools(registry, server);
84
+ registerAccountWizardTools(registry, server, () => servicesAwaitingRestart(registry));
85
85
  return registry;
86
86
  }
87
87
  /** The per-request hooks bound to one registry: argument shapes, unknown-
@@ -49,5 +49,8 @@ export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Prom
49
49
  export declare function clearDiscoveryMemoryCache(): void;
50
50
  export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
51
51
  export declare function expandPath(template: string, pathParams: Record<string, string>): string;
52
+ /** Search keywords are a few words; the caps keep a caller-sized query from
53
+ * driving index-size x token-count substring scans on the event loop. */
54
+ export declare const MAX_SEARCH_QUERY = 500;
52
55
  export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
53
56
  export {};
@@ -263,8 +263,13 @@ export function expandPath(template, pathParams) {
263
263
  return segments.map(encodeURIComponent).join('/');
264
264
  });
265
265
  }
266
+ /** Search keywords are a few words; the caps keep a caller-sized query from
267
+ * driving index-size x token-count substring scans on the event loop. */
268
+ export const MAX_SEARCH_QUERY = 500;
269
+ const MAX_SEARCH_TOKENS = 16;
266
270
  export function searchMethods(index, query, limit = 10) {
267
- const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
271
+ const words = query.slice(0, MAX_SEARCH_QUERY).toLowerCase().split(/\s+/).filter(Boolean);
272
+ const tokens = [...new Set(words)].slice(0, MAX_SEARCH_TOKENS);
268
273
  if (tokens.length === 0)
269
274
  return index.slice(0, limit);
270
275
  const scored = index
package/dist/fanout.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  export declare const CSV_RE: RegExp;
3
- export declare function fanoutAccountField(description: string, aliases: readonly string[]): z.ZodType;
3
+ export declare function fanoutAccountField(description: string, aliases: () => readonly string[]): z.ZodType;
4
+ /** '*' over an empty account set would fan out to nothing and report success. */
5
+ export declare function noAccountsResult(): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
4
12
  export type AccountSelector = {
5
13
  ok: true;
6
14
  fanout: boolean;
package/dist/fanout.js CHANGED
@@ -1,25 +1,47 @@
1
1
  import { z } from 'zod';
2
- import { getAccountSet, unknownAliasMessage } from './accounts.js';
2
+ import { getAccountSet, liveAccountCheck, unknownAliasMessage } from './accounts.js';
3
+ import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
3
4
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
4
5
  const FANOUT_CONCURRENCY = 5;
5
6
  // `aliases` is required (no global default): the field is baked into a
6
- // registry's inputSchema, so it must be built from THAT registry's account
7
- // set or a '*' would advertise and accept another registry's aliases.
7
+ // registry's inputSchema, so it must read THAT registry's account set or a
8
+ // '*' would advertise and accept another registry's aliases. It is a getter:
9
+ // the set is read at parse time, so an alias added mid-session (account_add,
10
+ // a tenant link) is valid on tools registered before it existed.
8
11
  export function fanoutAccountField(description, aliases) {
9
- const csvExample = aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
12
+ const boot = aliases();
13
+ const csvExample = boot.length > 1 ? `; or a CSV subset like "${boot.slice(0, 2).join(',')}"` : '';
10
14
  // The union's OWN error only fires when every branch aborts at the type
11
15
  // check (a non-string). For a string, zod surfaces the single non-aborted
12
16
  // branch verbatim, which is the CSV regex, so a mistyped alias used to read
13
17
  // as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
14
18
  // named an alias. Hence the same message on BOTH.
15
- const bad = () => unknownAliasMessage(aliases, true);
19
+ const bad = () => unknownAliasMessage(aliases(), true);
16
20
  return z
17
- // '*' first so the tuple is statically non-empty even when aliases is empty
18
- // (a fresh install): z.enum requires [string, ...string[]].
19
- .union([z.enum(['*', ...aliases]), z.string().regex(CSV_RE, { error: bad })], { error: bad })
21
+ // The selector branch stays first: tools/list injects the live
22
+ // '*' + alias enum into it (registry withLiveAccountEnum).
23
+ .union([z.string().check(liveAccountCheck(aliases, true)), z.string().regex(CSV_RE, { error: bad })], { error: bad })
20
24
  .optional()
21
25
  .describe(`${description}; "*" = all accounts${csvExample}; omit for the default account`);
22
26
  }
27
+ /** '*' over an empty account set would fan out to nothing and report success. */
28
+ export function noAccountsResult() {
29
+ return {
30
+ content: [
31
+ {
32
+ type: 'text',
33
+ text: JSON.stringify({
34
+ error: 'validation_error',
35
+ message: 'No Google account is configured, so "*" selects nothing.',
36
+ hint: unknownAliasMessage([]),
37
+ retriable: false,
38
+ account: '*',
39
+ }),
40
+ },
41
+ ],
42
+ isError: true,
43
+ };
44
+ }
23
45
  export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
24
46
  if (value === '*')
25
47
  return { ok: true, fanout: true, aliases: [...accounts] };
@@ -56,10 +78,11 @@ export function invalidAccountsResult(invalid, accounts = getAccountSet().aliase
56
78
  type: 'text',
57
79
  text: JSON.stringify({
58
80
  error: 'validation_error',
59
- message: `Unknown account alias(es): ${invalid.join(', ')}.`,
81
+ // The caller's CSV can hold any number of tokens: echo a prefix.
82
+ message: `Unknown account alias(es): ${echoNames(invalid)}.`,
60
83
  hint: unknownAliasMessage(accounts, true),
61
84
  retriable: false,
62
- account: invalid.join(','),
85
+ account: invalid.slice(0, MAX_SUGGESTED_KEYS).map(echoName).join(','),
63
86
  }),
64
87
  },
65
88
  ],
@@ -9,6 +9,10 @@ import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js
9
9
  // serialize() lock forever. Generous by default so slow-but-valid calls (large
10
10
  // Drive exports, fan-out) still finish; the point is only to guarantee release.
11
11
  const DISPATCH_TIMEOUT_DEFAULT = 120_000;
12
+ /** MCP 2025-06-18 dropped JSON-RPC batching, and the SDK screens every frame
13
+ * of a batch synchronously in one tick: without a cap one body multiplies the
14
+ * per-call work by however many frames fit in it. */
15
+ const MAX_BATCH_FRAMES = 16;
12
16
  export function parseOwnerEmails(env = process.env) {
13
17
  return (env.MCP_OWNER_EMAILS ?? '')
14
18
  .split(',')
@@ -198,6 +202,9 @@ export class HttpTransportHost {
198
202
  catch (e) {
199
203
  return this.fail(res, 400, 'invalid_body', e.message);
200
204
  }
205
+ if (Array.isArray(body) && body.length > MAX_BATCH_FRAMES) {
206
+ return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${MAX_BATCH_FRAMES} messages`);
207
+ }
201
208
  // Host/Origin are enforced by the front guard above (uniformly for /mcp and
202
209
  // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
203
210
  // exact-Host match is stricter than the front guard and would 403 valid
package/dist/index.js CHANGED
@@ -246,29 +246,16 @@ async function main() {
246
246
  registry.installListHandler();
247
247
  registerSetupPrompt(httpServer);
248
248
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
249
- const { buildAuthServer } = await import('./oauth-as.js');
249
+ const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
250
250
  const { jwtSecretFrom } = await import('./mcp-token.js');
251
251
  const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
252
252
  const { OAuth2Client } = await import('googleapis-common');
253
253
  const { writeToken } = await import('./token-store.js');
254
254
  const { resolveScopesForAccount } = await import('./auth.js');
255
+ const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
255
256
  const { configDir } = await import('./config-file.js');
256
257
  const { getAccountSet } = await import('./accounts.js');
257
258
  const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
258
- // Only trust the id_token email when Google marks it verified (#8).
259
- const verifiedEmailFromIdToken = (idToken) => {
260
- if (!idToken)
261
- return undefined;
262
- try {
263
- const [, payload] = idToken.split('.');
264
- const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
265
- const verified = claims.email_verified === true || claims.email_verified === 'true';
266
- return verified ? claims.email : undefined;
267
- }
268
- catch {
269
- return undefined;
270
- }
271
- };
272
259
  const authServer = buildAuthServer({
273
260
  base: httpCfg.publicUrl,
274
261
  resourceUri: httpCfg.resourceUri,
@@ -279,20 +266,7 @@ async function main() {
279
266
  masterKey: resolveMasterKeyForDispatch().key,
280
267
  refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
281
268
  }, {
282
- buildGoogleAuthUrl: ({ flow, alias, state }) => {
283
- if (flow === 'alias_reauth' && alias) {
284
- const cfg = getAccountSet().configs[alias];
285
- // openid+email so /callback can bind the returned identity to the alias.
286
- return googleClient().generateAuthUrl({
287
- access_type: 'offline',
288
- prompt: 'consent',
289
- scope: [...new Set([...resolveScopesForAccount(alias), 'openid', 'email'])],
290
- login_hint: cfg?.email,
291
- state,
292
- });
293
- }
294
- return googleClient().generateAuthUrl({ scope: ['openid', 'email'], prompt: 'select_account', state });
295
- },
269
+ buildGoogleAuthUrl: ({ flow, alias, state }) => googleClient().generateAuthUrl(ownerAuthUrlOptions({ flow, alias, state }, (a) => resolveScopesForAccount(a))),
296
270
  exchangeCode: async (code, flow) => {
297
271
  const { tokens } = await googleClient().getToken(code);
298
272
  const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
@@ -309,8 +283,7 @@ async function main() {
309
283
  const { setHttpReauthBase } = await import('./reauth-hint.js');
310
284
  setHttpReauthBase(httpCfg.publicUrl);
311
285
  // Wizard consent over HTTP: hand out the clientless AS link instead of
312
- // binding a loopback listener (single-owner flow; a tenancy host installs
313
- // its own signed-mint context here).
286
+ // binding a loopback listener.
314
287
  const { setWizardHttpConsent } = await import('./tools/account-wizard.js');
315
288
  setWizardHttpConsent({
316
289
  mintConsentUrl: (alias) => `${httpCfg.publicUrl}/authorize?flow=alias_reauth&alias=${encodeURIComponent(alias)}`,
@@ -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,18 +22,40 @@ 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). */
28
49
  exchangeCode?: (code: string, flow: StatePayload['flow']) => Promise<GoogleExchangeResult>;
29
50
  /** Build the Google authorization URL (redirect back to `${base}/callback`).
30
- * The impl chooses scope/prompt/login_hint from the flow: owner_gate =
31
- * `openid email` + select_account; alias_reauth = the alias's resource scopes
32
- * + consent. */
51
+ * The impl chooses scope/prompt from the flow: owner_gate = `openid email`
52
+ * + select_account; alias_reauth = the alias's resource scopes + select_account
53
+ * consent, and no login_hint (the link needs no authentication). */
33
54
  buildGoogleAuthUrl?: (opts: {
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