mcp-google-multi 6.0.0-alpha.67 → 6.0.0-alpha.69

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.
package/dist/client.js CHANGED
Binary file
package/dist/discover.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
+ import { hints } from './hint-provider.js';
3
4
  // The description budget is the whole point of lazy mode: a full op list per
4
5
  // service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
5
6
  // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
@@ -91,6 +92,7 @@ export function registerDiscoverTools(registry, policy) {
91
92
  }, async ({ query }) => {
92
93
  const operations = registry.catalog(service, query);
93
94
  registry.reveal(service);
95
+ const rendered = hints()?.render({ kind: 'policy_summary', policy });
94
96
  return {
95
97
  content: [
96
98
  {
@@ -98,7 +100,7 @@ export function registerDiscoverTools(registry, policy) {
98
100
  text: JSON.stringify({
99
101
  service,
100
102
  operations,
101
- writeControl: describePolicy(policy),
103
+ writeControl: rendered?.hint ?? describePolicy(policy),
102
104
  next: operations.length > 0
103
105
  ? 'Call the chosen tool by name; it is now listed and callable.'
104
106
  : `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
package/dist/doctor.js CHANGED
@@ -14,6 +14,7 @@ import { apiProbeDepsFor, probeApiEnablement } from './api-probe.js';
14
14
  import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
15
15
  import { resolveHttpConfig, HttpConfigError } from './http-config.js';
16
16
  import { parseOwnerEmails } from './http-transport.js';
17
+ import { hints } from './hint-provider.js';
17
18
  const MIN_NODE_MAJOR = 22;
18
19
  const DEFAULT_DEPS = {
19
20
  nodeVersion: process.versions.node,
@@ -70,9 +71,22 @@ export function apiEnableLink(api) {
70
71
  return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
71
72
  }
72
73
  const LEGACY_ENV_KEYS = ['GOOGLE_ACCOUNTS', 'GOOGLE_OPTIONAL_SCOPES', 'GOOGLE_ADMIN_ACCOUNTS'];
73
- function sectionRuntime(deps) {
74
+ function sectionRuntime(deps, tenantScope = false) {
74
75
  const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
75
76
  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
77
+ if (tenantScope) {
78
+ // The host version is operator infrastructure, not the tenant's (E7
79
+ // residue): no version string, and the fix is the operator's to apply.
80
+ const rendered = hints()?.render({ kind: 'operator_only', slug: 'E_NODE_TOO_OLD' });
81
+ return {
82
+ id: 1,
83
+ title: 'Runtime',
84
+ verdict: 'fail',
85
+ slug: 'E_NODE_TOO_OLD',
86
+ lines: ['Runtime below the supported version'],
87
+ hint: rendered?.hint ?? 'Tell the server operator the code E_NODE_TOO_OLD.',
88
+ };
89
+ }
76
90
  return {
77
91
  id: 1,
78
92
  title: 'Runtime',
@@ -82,7 +96,12 @@ function sectionRuntime(deps) {
82
96
  hint: `Upgrade to Node ${MIN_NODE_MAJOR} LTS or newer.`,
83
97
  };
84
98
  }
85
- return { id: 1, title: 'Runtime', verdict: 'ok', lines: [`Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`] };
99
+ return {
100
+ id: 1,
101
+ title: 'Runtime',
102
+ verdict: 'ok',
103
+ lines: [tenantScope ? 'Runtime ok' : `Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`],
104
+ };
86
105
  }
87
106
  function sectionConfig(deps, set, tenantScope = false) {
88
107
  const lines = [];
@@ -90,13 +109,16 @@ function sectionConfig(deps, set, tenantScope = false) {
90
109
  let hint;
91
110
  let slug;
92
111
  if (!set || set.aliases.length === 0) {
112
+ const tenantHint = tenantScope ? (hints()?.render({ kind: 'no_accounts' })?.hint ?? '') : undefined;
93
113
  return {
94
114
  id: 2,
95
115
  title: 'Config',
96
116
  verdict: 'fail',
97
117
  slug: 'E_NO_ACCOUNTS_CONFIGURED',
98
118
  lines: ['No accounts configured.'],
99
- ...(tenantScope ? {} : { hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.' }),
119
+ ...(tenantScope
120
+ ? (tenantHint ? { hint: tenantHint } : {})
121
+ : { hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.' }),
100
122
  };
101
123
  }
102
124
  // The config file, process env, working directory and metrics store below
@@ -188,7 +210,8 @@ function sectionsTokensAndScopes(deps, aliases) {
188
210
  if (scopeVerdict === 'ok')
189
211
  scopeVerdict = 'warn';
190
212
  scopeLines.push(`${alias}: ${r.callable.length} callable, ${r.requestable.length} requested-not-granted`);
191
- scopeHint = `Re-auth to grant missing scopes. ${reauthHint(alias)}`;
213
+ const rendered = hints()?.render({ kind: 'scopes_missing', account: alias });
214
+ scopeHint = rendered?.hint ?? `Re-auth to grant missing scopes. ${reauthHint(alias)}`;
192
215
  }
193
216
  else {
194
217
  scopeLines.push(`${alias}: ${r.callable.length} callable, all profile scopes granted`);
@@ -199,7 +222,7 @@ function sectionsTokensAndScopes(deps, aliases) {
199
222
  { id: 5, title: 'Scopes', verdict: scopeVerdict, lines: scopeLines, ...(scopeHint ? { hint: scopeHint } : {}), ...(scopeVerdict === 'warn' ? { slug: 'E_SCOPE_NOT_GRANTED' } : {}) },
200
223
  ];
201
224
  }
202
- async function sectionApiEnablement(deps, aliases) {
225
+ async function sectionApiEnablement(deps, aliases, tenantScope = false) {
203
226
  if (!deps.probeApi) {
204
227
  return { id: 6, title: 'API enablement', verdict: 'unknown', lines: ['Probe not run (no live account probe configured).'] };
205
228
  }
@@ -216,8 +239,15 @@ async function sectionApiEnablement(deps, aliases) {
216
239
  results = await deps.probeApi(healthy);
217
240
  }
218
241
  catch (e) {
219
- // Network / transient: WARN with the target, never crash the report.
220
- return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
242
+ // Network / transient: WARN with the target, never crash the report. The
243
+ // raw text can name an egress or proxy host (E7 residue): tenant scope
244
+ // drops it.
245
+ return {
246
+ id: 6,
247
+ title: 'API enablement',
248
+ verdict: 'warn',
249
+ lines: [tenantScope ? 'Probe could not complete.' : `Probe could not complete: ${e?.message ?? e}`],
250
+ };
221
251
  }
222
252
  if (results.length === 0) {
223
253
  return { id: 6, title: 'API enablement', verdict: 'unknown', lines: [`No probeable service scopes granted on "${healthy}".`] };
@@ -225,13 +255,14 @@ async function sectionApiEnablement(deps, aliases) {
225
255
  const disabled = results.filter((r) => r.notEnabled);
226
256
  const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
227
257
  if (disabled.length > 0) {
258
+ const rendered = hints()?.render({ kind: 'apis_disabled', services: disabled.map((r) => ({ service: r.service, api: r.api })) });
228
259
  return {
229
260
  id: 6,
230
261
  title: 'API enablement',
231
262
  verdict: 'fail',
232
263
  slug: 'E_API_NOT_ENABLED',
233
264
  lines,
234
- hint: disabled.map((r) => `Enable ${r.service}: ${apiEnableLink(r.api)}`).join('\n'),
265
+ hint: rendered?.hint ?? disabled.map((r) => `Enable ${r.service}: ${apiEnableLink(r.api)}`).join('\n'),
235
266
  };
236
267
  }
237
268
  return { id: 6, title: 'API enablement', verdict: 'ok', lines: lines.length ? lines : ['(probed account, all enabled)'] };
@@ -313,7 +344,7 @@ export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
313
344
  // the context's own account count.
314
345
  const tenantScope = opts.scope === 'tenant';
315
346
  const sections = [];
316
- sections.push(sectionRuntime(deps));
347
+ sections.push(sectionRuntime(deps, tenantScope));
317
348
  const set = deps.accountSet();
318
349
  sections.push(sectionConfig(deps, set, tenantScope));
319
350
  const aliases = set?.aliases ?? [];
@@ -322,7 +353,7 @@ export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
322
353
  if (aliases.length > 0) {
323
354
  const [tokens, scopes] = sectionsTokensAndScopes(deps, aliases);
324
355
  sections.push(tokens, scopes);
325
- sections.push(await sectionApiEnablement(deps, aliases));
356
+ sections.push(await sectionApiEnablement(deps, aliases, tenantScope));
326
357
  }
327
358
  if (!tenantScope) {
328
359
  const http = await sectionHttp(deps, aliases);
package/dist/executor.js CHANGED
@@ -4,6 +4,7 @@ import { expandPath, isGoogleApiUrl } from './discovery-client.js';
4
4
  import { handleGoogleApiError } from './tools/_errors.js';
5
5
  import { scopeHintForMethod } from './scope-observability.js';
6
6
  import { checkOutboundForMethod } from './outbound-allowlist.js';
7
+ import { hints } from './hint-provider.js';
7
8
  export const MAX_RESPONSE_CHARS = 100_000;
8
9
  export function jsonResult(payload, isError = false) {
9
10
  const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -70,10 +71,11 @@ export async function executeApiMethod(method, args, deps = {}) {
70
71
  }, true);
71
72
  }
72
73
  if (!isGoogleApiUrl(url)) {
74
+ const rendered = hints()?.render({ kind: 'untrusted_host' });
73
75
  return jsonResult({
74
76
  error: 'untrusted_host',
75
77
  message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
76
- hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
78
+ hint: rendered?.hint ?? 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
77
79
  retriable: false,
78
80
  account: args.account,
79
81
  }, true);
package/dist/fanout.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { getAccountSet, liveAccountCheck, unknownAliasMessage } from './accounts.js';
3
3
  import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
4
+ import { hints } from './hint-provider.js';
4
5
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
5
6
  const FANOUT_CONCURRENCY = 5;
6
7
  // `aliases` is required (no global default): the field is baked into a
@@ -113,10 +114,17 @@ export async function runFanout(handler, args, aliases) {
113
114
  results[i] = res?.isError ? { account, ok: false, error: payload } : { account, ok: true, data: payload };
114
115
  }
115
116
  catch (err) {
117
+ const rendered = hints()?.render({ kind: 'internal', account });
116
118
  results[i] = {
117
119
  account,
118
120
  ok: false,
119
- error: { error: 'internal', message: err.message, retriable: false, account },
121
+ error: {
122
+ error: 'internal',
123
+ message: rendered?.message ?? err.message,
124
+ hint: rendered?.hint,
125
+ retriable: false,
126
+ account,
127
+ },
120
128
  };
121
129
  }
122
130
  }
@@ -0,0 +1,129 @@
1
+ import type { DenyReason, Policy } from './write-control.js';
2
+ export type HintCase = {
3
+ kind: 'reauth';
4
+ account: string;
5
+ } | {
6
+ kind: 'legacy_plaintext_token';
7
+ account: string;
8
+ } | {
9
+ kind: 'dead_refresh_token';
10
+ account: string;
11
+ } | {
12
+ kind: 'token_unreadable';
13
+ account: string;
14
+ } | {
15
+ kind: 'scopes_missing';
16
+ account: string;
17
+ } | {
18
+ kind: 'scope_not_granted';
19
+ account: string;
20
+ scope: string;
21
+ } | {
22
+ kind: 'scope_needs_bundle';
23
+ account: string;
24
+ scope: string;
25
+ bundle: string;
26
+ } | {
27
+ kind: 'scope_base_excluded';
28
+ account: string;
29
+ scope: string;
30
+ } | {
31
+ kind: 'scope_account_type';
32
+ account: string;
33
+ scope: string;
34
+ } | {
35
+ kind: 'scope_unknown_scope';
36
+ scope: string;
37
+ } | {
38
+ kind: 'scope_unknown';
39
+ account: string;
40
+ serviceText?: string;
41
+ bundle?: string;
42
+ } | {
43
+ kind: 'write_denied';
44
+ tool: {
45
+ name: string;
46
+ service: string;
47
+ cud: string;
48
+ };
49
+ reason: DenyReason;
50
+ policy: Policy;
51
+ } | {
52
+ kind: 'policy_summary';
53
+ policy: Policy;
54
+ } | {
55
+ kind: 'outbound_denied';
56
+ what: string;
57
+ blocked: string[];
58
+ entries: string[];
59
+ } | {
60
+ kind: 'no_default_account';
61
+ aliases: string[];
62
+ } | {
63
+ kind: 'no_accounts';
64
+ } | {
65
+ kind: 'api_not_enabled';
66
+ api?: string;
67
+ service?: string;
68
+ project?: string;
69
+ googleMessage: string;
70
+ } | {
71
+ kind: 'apis_disabled';
72
+ services: {
73
+ service: string;
74
+ api: string;
75
+ }[];
76
+ } | {
77
+ kind: 'daily_quota';
78
+ } | {
79
+ kind: 'project_suspended';
80
+ account: string;
81
+ } | {
82
+ kind: 'invalid_scope';
83
+ account: string;
84
+ } | {
85
+ kind: 'no_oauth_client';
86
+ } | {
87
+ kind: 'network_error';
88
+ code: string;
89
+ } | {
90
+ kind: 'local_path';
91
+ code: string;
92
+ path?: string;
93
+ } | {
94
+ kind: 'internal';
95
+ account?: string;
96
+ } | {
97
+ kind: 'untrusted_host';
98
+ } | {
99
+ kind: 'toolset_disabled';
100
+ api: string;
101
+ service: string;
102
+ } | {
103
+ kind: 'service_not_enabled';
104
+ tool: string;
105
+ service: string;
106
+ bundle?: string;
107
+ aliases: string[];
108
+ admin: boolean;
109
+ toolsetOff: boolean;
110
+ awaitingRestart: boolean;
111
+ } | {
112
+ kind: 'operator_only';
113
+ slug: string;
114
+ };
115
+ export declare const HINT_KINDS: readonly ["reauth", "legacy_plaintext_token", "dead_refresh_token", "token_unreadable", "scopes_missing", "scope_not_granted", "scope_needs_bundle", "scope_base_excluded", "scope_account_type", "scope_unknown_scope", "scope_unknown", "write_denied", "policy_summary", "outbound_denied", "no_default_account", "no_accounts", "api_not_enabled", "apis_disabled", "daily_quota", "project_suspended", "invalid_scope", "no_oauth_client", "network_error", "local_path", "internal", "untrusted_host", "toolset_disabled", "service_not_enabled", "operator_only"];
116
+ /** `message` only for the kinds whose message leaks today (write_denied,
117
+ * no_oauth_client, local_path, internal, toolset_disabled); every other call
118
+ * site ignores it. `retriable` only where it may legitimately differ from
119
+ * the owner's default (scope_not_granted). */
120
+ export interface Rendered {
121
+ hint: string;
122
+ message?: string;
123
+ retriable?: boolean;
124
+ }
125
+ export interface HintProvider {
126
+ render(c: HintCase): Rendered;
127
+ }
128
+ export declare function setHintProvider(p: HintProvider | null): void;
129
+ export declare function hints(): HintProvider | null;
@@ -0,0 +1,64 @@
1
+ // A plain array literal, not derived from the union: deriving it would need a
2
+ // value-level mirror of the type anyway, and this way a missing member is
3
+ // caught by the completeness check below instead of silently compiling.
4
+ // `as const` so that check narrows to the literal tuple rather than widening
5
+ // every element back to the whole HintCase['kind'] union (which would make
6
+ // it pass trivially, whatever was listed).
7
+ export const HINT_KINDS = [
8
+ 'reauth',
9
+ 'legacy_plaintext_token',
10
+ 'dead_refresh_token',
11
+ 'token_unreadable',
12
+ 'scopes_missing',
13
+ 'scope_not_granted',
14
+ 'scope_needs_bundle',
15
+ 'scope_base_excluded',
16
+ 'scope_account_type',
17
+ 'scope_unknown_scope',
18
+ 'scope_unknown',
19
+ 'write_denied',
20
+ 'policy_summary',
21
+ 'outbound_denied',
22
+ 'no_default_account',
23
+ 'no_accounts',
24
+ 'api_not_enabled',
25
+ 'apis_disabled',
26
+ 'daily_quota',
27
+ 'project_suspended',
28
+ 'invalid_scope',
29
+ 'no_oauth_client',
30
+ 'network_error',
31
+ 'local_path',
32
+ 'internal',
33
+ 'untrusted_host',
34
+ 'toolset_disabled',
35
+ 'service_not_enabled',
36
+ 'operator_only',
37
+ ];
38
+ const _hintKindsComplete = true;
39
+ void _hintKindsComplete;
40
+ // Process-global, like the HTTP reauth linker: one process is either the
41
+ // single owner or one multi-tenant host, never both.
42
+ let installed = null;
43
+ export function setHintProvider(p) {
44
+ installed = p;
45
+ }
46
+ // A throwing provider must never take down the call site: every call site
47
+ // does `hints()?.render(...)?.hint ?? <default>`, so a render that cannot
48
+ // produce a Rendered falls back to the same literal a missing provider
49
+ // would, and the provider's own error text never reaches a client.
50
+ export function hints() {
51
+ if (!installed)
52
+ return null;
53
+ const provider = installed;
54
+ return {
55
+ render(c) {
56
+ try {
57
+ return provider.render(c);
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ },
63
+ };
64
+ }
@@ -42,7 +42,17 @@ export interface HttpHostOptions {
42
42
  * registry, never another subject's. */
43
43
  resolveServer?: (claims: {
44
44
  sub: string;
45
- }) => Promise<ServerTarget | null> | ServerTarget | null;
45
+ }) => Promise<ServerTarget | null | {
46
+ refused: {
47
+ error: string;
48
+ message: string;
49
+ };
50
+ }> | ServerTarget | null | {
51
+ refused: {
52
+ error: string;
53
+ message: string;
54
+ };
55
+ };
46
56
  /** Extra routes keyed by exact pathname (AS endpoints mount here in B13). */
47
57
  routes?: Record<string, RouteHandler>;
48
58
  log?: (line: string) => void;
@@ -16,6 +16,24 @@ const DISPATCH_TIMEOUT_DEFAULT = 120_000;
16
16
  * of a batch synchronously in one tick: without a cap one body multiplies the
17
17
  * per-call work by however many frames fit in it. */
18
18
  const MAX_BATCH_FRAMES = 16;
19
+ // A resolveServer refusal's body reaches the wire verbatim (H3 PR1): validated
20
+ // so a host cannot accidentally leak an operator path/env var through a wrongly
21
+ // shaped slug or an overlong message. No new slug literal enters core itself —
22
+ // the slug is always the host's own value.
23
+ const REFUSAL_SLUG_RE = /^[a-z][a-z0-9_]{0,39}$/;
24
+ const MAX_REFUSAL_MESSAGE_CHARS = 300;
25
+ // C0/C1 controls, format chars (includes bidi overrides like U+202E and
26
+ // zero-width joiners) and the line/paragraph separators; ordinary non-ASCII
27
+ // letters stay allowed.
28
+ const NON_PRINTABLE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
29
+ function isValidRefusal(r) {
30
+ return (typeof r.error === 'string' &&
31
+ REFUSAL_SLUG_RE.test(r.error) &&
32
+ typeof r.message === 'string' &&
33
+ r.message.length > 0 &&
34
+ r.message.length <= MAX_REFUSAL_MESSAGE_CHARS &&
35
+ !NON_PRINTABLE_RE.test(r.message));
36
+ }
19
37
  export function parseOwnerEmails(env = process.env) {
20
38
  return (env.MCP_OWNER_EMAILS ?? '')
21
39
  .split(',')
@@ -366,12 +384,27 @@ export class HttpTransportHost {
366
384
  validationEnvelope: this.opts.validationEnvelope,
367
385
  };
368
386
  if (this.opts.resolveServer) {
387
+ let resolved;
369
388
  try {
370
- target = await this.opts.resolveServer({ sub });
389
+ resolved = await this.opts.resolveServer({ sub });
371
390
  }
372
391
  catch {
373
- target = null;
392
+ resolved = null;
393
+ }
394
+ if (resolved && 'refused' in resolved) {
395
+ const { refused } = resolved;
396
+ if (isValidRefusal(refused)) {
397
+ res.writeHead(403, { 'Content-Type': 'application/json' });
398
+ res.end(JSON.stringify({ error: refused.error, message: refused.message }));
399
+ this.log(`403 ${refused.error} path=/mcp`);
400
+ return;
401
+ }
402
+ // An invalid shape falls through to today's exact answer rather than
403
+ // forwarding something unvalidated; a refusal never reaches dispatch
404
+ // either way.
405
+ resolved = null;
374
406
  }
407
+ target = resolved;
375
408
  if (!target) {
376
409
  res.writeHead(403, { 'Content-Type': 'application/json' });
377
410
  res.end(JSON.stringify({ error: 'tenant_not_found', message: 'no tenant is provisioned for this subject' }));
@@ -123,6 +123,14 @@ export interface RefreshStoreOptions {
123
123
  maxAgeSec?: number;
124
124
  /** Idle lifetime in seconds, counted from the last issue or rotation. Unset: unlimited. */
125
125
  idleSec?: number;
126
+ /** Max live refresh-token families per subject (both formats). Unset:
127
+ * unlimited (default; the single-owner server passes none). When issuing a
128
+ * new family would put a subject over this, that subject's own
129
+ * least-recently-used family is dropped (by its last issue or rotation
130
+ * time) until it fits; a dropped family behaves exactly like a revoked
131
+ * one. Only the subject signing in ever triggers it, and only its own
132
+ * families are ever touched. */
133
+ maxFamiliesPerSub?: number;
126
134
  /** Server-side log for a revoked, dropped or expired family. Never given a token or a subject. */
127
135
  log?: (line: string) => void;
128
136
  }
@@ -141,6 +149,7 @@ export declare class RefreshStore {
141
149
  private readonly log;
142
150
  private readonly maxAgeMs;
143
151
  private readonly idleMs;
152
+ private readonly maxFamiliesPerSub;
144
153
  private macKey;
145
154
  private legacyEmpty;
146
155
  constructor(path: string, masterKey: string, options?: RefreshStoreOptions);
@@ -156,6 +165,17 @@ export declare class RefreshStore {
156
165
  private load;
157
166
  private save;
158
167
  private emit;
168
+ /** Evict `sub`'s own least-recently-used sessions (both formats) so at
169
+ * most `limit` of its live sessions remain; dropping one here is the same
170
+ * as deleting it from `data.families` / `data.active` on a revoke, so it
171
+ * can never be presented again (reuse detection for every OTHER family is
172
+ * untouched). Only `sub`'s own entries are ever inspected.
173
+ *
174
+ * `keepKey` is the family just issued: it is excluded from the ranking so
175
+ * a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
176
+ * racing ahead of it under the lock, can never make the brand-new family
177
+ * look like the least recently used one and drop itself. */
178
+ private enforceFamilyCap;
159
179
  private newFamilyId;
160
180
  issue(nowMs: number, sub: string): string;
161
181
  /** Rotate a presented refresh token; null if it is unknown, malformed, or
package/dist/mcp-token.js CHANGED
@@ -363,12 +363,16 @@ export function assertRefreshStoreReadable(file, masterKey) {
363
363
  }
364
364
  checkFormat(file, d);
365
365
  }
366
- function lifetimeMs(name, v) {
366
+ function positiveInt(name, v) {
367
367
  if (v === undefined)
368
368
  return undefined;
369
369
  if (typeof v !== 'number' || !Number.isSafeInteger(v) || v <= 0)
370
370
  throw new Error(`E_REFRESH_OPTION_INVALID: ${name} must be a positive whole number`);
371
- return v * 1000;
371
+ return v;
372
+ }
373
+ function lifetimeMs(name, v) {
374
+ const n = positiveInt(name, v);
375
+ return n === undefined ? undefined : n * 1000;
372
376
  }
373
377
  /**
374
378
  * Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
@@ -385,6 +389,7 @@ export class RefreshStore {
385
389
  log;
386
390
  maxAgeMs;
387
391
  idleMs;
392
+ maxFamiliesPerSub;
388
393
  macKey;
389
394
  // Set once a load finds no earlier-release record. Nothing here adds one,
390
395
  // so from then on a legacy-shaped token is refused before the lock.
@@ -394,6 +399,7 @@ export class RefreshStore {
394
399
  this.masterKey = masterKey;
395
400
  this.maxAgeMs = lifetimeMs('maxAgeSec', options.maxAgeSec);
396
401
  this.idleMs = lifetimeMs('idleSec', options.idleSec);
402
+ this.maxFamiliesPerSub = positiveInt('maxFamiliesPerSub', options.maxFamiliesPerSub);
397
403
  this.log = options.log ?? (() => undefined);
398
404
  }
399
405
  get timed() {
@@ -502,6 +508,47 @@ export class RefreshStore {
502
508
  }
503
509
  }
504
510
  }
511
+ /** Evict `sub`'s own least-recently-used sessions (both formats) so at
512
+ * most `limit` of its live sessions remain; dropping one here is the same
513
+ * as deleting it from `data.families` / `data.active` on a revoke, so it
514
+ * can never be presented again (reuse detection for every OTHER family is
515
+ * untouched). Only `sub`'s own entries are ever inspected.
516
+ *
517
+ * `keepKey` is the family just issued: it is excluded from the ranking so
518
+ * a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
519
+ * racing ahead of it under the lock, can never make the brand-new family
520
+ * look like the least recently used one and drop itself. */
521
+ enforceFamilyCap(data, sub, limit, lines, keepKey) {
522
+ const entries = [];
523
+ for (const [k, f] of Object.entries(data.families)) {
524
+ if (f.sub !== sub || k === keepKey)
525
+ continue;
526
+ entries.push({
527
+ at: f.usedAt,
528
+ drop: () => {
529
+ delete data.families[k];
530
+ lines.push(`refresh family dropped: cap tag=${familyTag(Buffer.from(k, 'base64url'))}`);
531
+ },
532
+ });
533
+ }
534
+ for (const [t, r] of Object.entries(data.active)) {
535
+ if (r.sub !== sub)
536
+ continue;
537
+ entries.push({
538
+ at: r.issuedAt,
539
+ drop: () => {
540
+ delete data.active[t];
541
+ lines.push(`refresh family dropped: cap tag=${familyTag(r.family)}`);
542
+ },
543
+ });
544
+ }
545
+ // +1 accounts for the kept family, which stays live but is never ranked.
546
+ if (entries.length + 1 <= limit)
547
+ return;
548
+ entries.sort((a, b) => a.at - b.at);
549
+ for (const e of entries.slice(0, entries.length + 1 - limit))
550
+ e.drop();
551
+ }
505
552
  newFamilyId(data) {
506
553
  let fid;
507
554
  do
@@ -517,8 +564,11 @@ export class RefreshStore {
517
564
  const lines = [];
518
565
  const { data } = this.load(nowMs, lines);
519
566
  const fid = this.newFamilyId(data);
567
+ const fidKey = fid.toString('base64url');
520
568
  const { token, cur } = mintR1(key, fid, 0);
521
- data.families[fid.toString('base64url')] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
569
+ data.families[fidKey] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
570
+ if (this.maxFamiliesPerSub !== undefined)
571
+ this.enforceFamilyCap(data, sub, this.maxFamiliesPerSub, lines, fidKey);
522
572
  this.save(data);
523
573
  this.emit(lines);
524
574
  return token;
@@ -26,6 +26,10 @@ export interface AuthServerConfig {
26
26
  /** `idleSec` of that store: sessions end this long after their last
27
27
  * refresh. Unset: unlimited. */
28
28
  refreshIdleSec?: number;
29
+ /** `maxFamiliesPerSub` of that store: bounds how many live refresh-token
30
+ * families one subject may hold, dropping its least-recently-used one to
31
+ * make room. Unset: unlimited. */
32
+ refreshMaxFamiliesPerSub?: number;
29
33
  /** Default true. The clientless `flow=alias_reauth` link names an alias with
30
34
  * no tenant binding, so a multi-tenant deployment sets false: with several
31
35
  * tenants an alias name alone is ambiguous and the branch becomes a
@@ -103,7 +107,11 @@ export interface AuthServerDeps {
103
107
  hd?: string;
104
108
  }) => {
105
109
  sub: string;
106
- } | null;
110
+ } | null | {
111
+ denied: {
112
+ description: string;
113
+ };
114
+ };
107
115
  /** alias_add completion: persist the new alias's config row and tokens under
108
116
  * its tenant. /callback awaits it only after a successful Google exchange.
109
117
  * A returned refusal answers 403 with its slug; a throw or rejection answers