mcp-google-multi 6.0.0-alpha.36 → 6.0.0-alpha.38

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/accounts.js CHANGED
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { z } from 'zod';
4
4
  import { loadEnvFiles } from './env-load.js';
5
- import { CONFIG_VERSION, configDir, configFilePath, failStartup, loadConfigFile, mutateConfigFile } from './config-file.js';
5
+ import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
6
6
  import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
7
7
  const envLoad = loadEnvFiles();
8
8
  const defaultTokenDir = path.join(configDir(), 'tokens');
@@ -40,7 +40,7 @@ function parseEnvAccounts(raw, adminAliases) {
40
40
  }
41
41
  // Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
42
42
  // (e.g. "../../etc/passwd:foo@bar.com" in .env).
43
- if (!/^[a-zA-Z0-9_-]+$/.test(alias) || ['__proto__', 'constructor', 'prototype'].includes(alias)) {
43
+ if (!ALIAS_RE.test(alias) || isReservedAlias(alias)) {
44
44
  throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
45
45
  }
46
46
  if (aliases.includes(alias)) {
@@ -2,6 +2,8 @@ export declare const CONFIG_VERSION = 1;
2
2
  export declare function configDir(env?: NodeJS.ProcessEnv): string;
3
3
  export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
4
4
  export declare const ALIAS_RE: RegExp;
5
+ export declare const RESERVED_ALIASES: string[];
6
+ export declare function isReservedAlias(name: string): boolean;
5
7
  export interface ConfigFile {
6
8
  version: number;
7
9
  accounts?: Record<string, {
@@ -22,7 +22,10 @@ const accountEntrySchema = z.strictObject({
22
22
  // masterKey, jwtKey, clientId) fails validation, structurally enforcing
23
23
  // secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
24
24
  // of the frozen envelope; their consumers land in later slices.
25
- const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
25
+ export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
26
+ export function isReservedAlias(name) {
27
+ return RESERVED_ALIASES.includes(name);
28
+ }
26
29
  const configSchema = z.strictObject({
27
30
  version: z.number().int(),
28
31
  accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
package/dist/doctor.js CHANGED
@@ -7,6 +7,7 @@ import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.j
7
7
  import { hasToken } from './token-store.js';
8
8
  import { configDir, loadConfigFile } from './config-file.js';
9
9
  import { envValueSource } from './env-load.js';
10
+ import { reauthHint } from './reauth-hint.js';
10
11
  import { describeMetricsDir, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
11
12
  import { probeApiEnablement } from './api-probe.js';
12
13
  import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
@@ -181,7 +182,7 @@ function sectionsTokensAndScopes(deps, aliases) {
181
182
  if (scopeVerdict === 'ok')
182
183
  scopeVerdict = 'warn';
183
184
  scopeLines.push(`${alias}: ${r.callable.length} callable, ${r.requestable.length} requested-not-granted`);
184
- scopeHint = `Re-auth to grant missing scopes: \`npx mcp-google-multi auth --account ${alias}\`.`;
185
+ scopeHint = `Re-auth to grant missing scopes. ${reauthHint(alias)}`;
185
186
  }
186
187
  else {
187
188
  scopeLines.push(`${alias}: ${r.callable.length} callable, all profile scopes granted`);
@@ -4,6 +4,7 @@ import type { HttpConfig } from './http-config.js';
4
4
  import { type ArgShape, type StrictArgOptions, type ValidationEnvelopeOptions } from './arg-normalize.js';
5
5
  export type AuthOutcome = {
6
6
  ok: true;
7
+ sub?: string;
7
8
  } | {
8
9
  ok: false;
9
10
  status: number;
@@ -149,6 +149,15 @@ export class HttpTransportHost {
149
149
  this.log(`${auth.status} auth_failed path=/mcp`);
150
150
  return;
151
151
  }
152
+ // Thread the verified subject to tool handlers: the Node transport forwards
153
+ // req.auth verbatim as ctx.http.authInfo. Token/clientId stay empty — the
154
+ // bearer value must not re-enter the dispatch path via handler context.
155
+ req.auth = {
156
+ token: '',
157
+ clientId: '',
158
+ scopes: ['mcp:use'],
159
+ extra: { sub: auth.sub ?? 'owner' },
160
+ };
152
161
  let body;
153
162
  try {
154
163
  body = await this.readJson(req);
@@ -38,17 +38,23 @@ let keychainFactory = systemKeychain;
38
38
  export function __setKeychainFactoryForTest(fn) {
39
39
  keychainFactory = fn ?? systemKeychain;
40
40
  }
41
- // Glob the token dir instead of iterating registered aliases: an orphan .enc
42
- // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
43
- // one client) must still stop a fresh key from being minted.
44
- function anyTokenFileExists() {
41
+ function anyEncFileUnder(dir) {
45
42
  try {
46
- return fs.readdirSync(getTokenDir()).some((f) => f.endsWith('.enc'));
43
+ return fs.readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith('.enc'));
47
44
  }
48
45
  catch {
49
46
  return false;
50
47
  }
51
48
  }
49
+ // Glob the token dir instead of iterating registered aliases: an orphan .enc
50
+ // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
51
+ // one client) must still stop a fresh key from being minted. The walk is
52
+ // recursive and also covers configDir()/tenants (the tenant-namespaced token
53
+ // root): a flat scan returns false the moment tokens nest, and minting a
54
+ // fresh MASTER_KEY over existing .enc files bricks them irreversibly.
55
+ function anyTokenFileExists() {
56
+ return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(path.join(configDir(), 'tenants'));
57
+ }
52
58
  function readKeyFile(filePath) {
53
59
  try {
54
60
  const raw = fs.readFileSync(filePath, 'utf8').trim();
@@ -9,6 +9,7 @@ export interface AccessTokenParams {
9
9
  secret: Uint8Array;
10
10
  ttlSec?: number;
11
11
  iat: number;
12
+ sub: string;
12
13
  }
13
14
  export declare function signAccessToken(p: AccessTokenParams): Promise<string>;
14
15
  export interface AccessClaims {
@@ -31,7 +32,7 @@ export interface CodePayload {
31
32
  redirect_uri: string;
32
33
  code_challenge: string;
33
34
  resource: string;
34
- sub: 'owner';
35
+ sub: string;
35
36
  }
36
37
  export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
37
38
  export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
@@ -57,7 +58,7 @@ export declare class ReplayGuard {
57
58
  private evictExpired;
58
59
  }
59
60
  export interface RefreshRecord {
60
- sub: 'owner';
61
+ sub: string;
61
62
  issuedAt: number;
62
63
  family: string;
63
64
  }
@@ -74,8 +75,13 @@ export declare class RefreshStore {
74
75
  constructor(path: string, masterKey: string);
75
76
  private load;
76
77
  private save;
77
- issue(nowMs: number, family?: string): string;
78
+ issue(nowMs: number, sub: string, family?: string): string;
78
79
  /** Rotate a presented refresh token; null if unknown OR if the presented
79
- * token was already rotated away (reuse => the family is revoked). */
80
- rotate(oldToken: string, nowMs: number): string | null;
80
+ * token was already rotated away (reuse => the family is revoked). The
81
+ * record's sub is copied forward and returned so the caller can mint the
82
+ * matching access token without trusting anything client-supplied. */
83
+ rotate(oldToken: string, nowMs: number): {
84
+ token: string;
85
+ sub: string;
86
+ } | null;
81
87
  }
package/dist/mcp-token.js CHANGED
@@ -24,7 +24,7 @@ export async function signAccessToken(p) {
24
24
  return new SignJWT({ scope: 'mcp:use', purpose: 'mcp_access' })
25
25
  .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
26
26
  .setIssuer(p.base)
27
- .setSubject('owner')
27
+ .setSubject(p.sub)
28
28
  .setAudience(`${p.base}/mcp`)
29
29
  .setIssuedAt(p.iat)
30
30
  .setExpirationTime(p.iat + ttl)
@@ -139,17 +139,19 @@ export class RefreshStore {
139
139
  }
140
140
  atomicWriteFileSync(this.path, encryptToken(data, this.masterKey), 0o600);
141
141
  }
142
- issue(nowMs, family) {
142
+ issue(nowMs, sub, family) {
143
143
  return withFileLock(this.path, () => {
144
144
  const token = randomBytes(32).toString('base64url');
145
145
  const data = this.load();
146
- data.active[token] = { sub: 'owner', issuedAt: nowMs, family: family ?? randomBytes(12).toString('hex') };
146
+ data.active[token] = { sub, issuedAt: nowMs, family: family ?? randomBytes(12).toString('hex') };
147
147
  this.save(data);
148
148
  return token;
149
149
  });
150
150
  }
151
151
  /** Rotate a presented refresh token; null if unknown OR if the presented
152
- * token was already rotated away (reuse => the family is revoked). */
152
+ * token was already rotated away (reuse => the family is revoked). The
153
+ * record's sub is copied forward and returned so the caller can mint the
154
+ * matching access token without trusting anything client-supplied. */
153
155
  rotate(oldToken, nowMs) {
154
156
  return withFileLock(this.path, () => {
155
157
  const data = this.load();
@@ -168,9 +170,9 @@ export class RefreshStore {
168
170
  delete data.active[oldToken];
169
171
  data.spent[oldToken] = rec.family;
170
172
  const next = randomBytes(32).toString('base64url');
171
- data.active[next] = { sub: 'owner', issuedAt: nowMs, family: rec.family };
173
+ data.active[next] = { sub: rec.sub, issuedAt: nowMs, family: rec.family };
172
174
  this.save(data);
173
- return next;
175
+ return { token: next, sub: rec.sub };
174
176
  });
175
177
  }
176
178
  }
package/dist/oauth-as.js CHANGED
@@ -385,8 +385,8 @@ export function buildAuthServer(config, deps = {}) {
385
385
  if (!form.code_verifier || pkceS256(form.code_verifier) !== code.code_challenge) {
386
386
  return json(res, 400, { error: 'invalid_grant', message: 'PKCE verification failed' });
387
387
  }
388
- const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
389
- const refreshToken = refresh.issue(now());
388
+ const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: code.sub });
389
+ const refreshToken = refresh.issue(now(), code.sub);
390
390
  log('token issued grant=authorization_code');
391
391
  return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
392
392
  }
@@ -394,9 +394,9 @@ export function buildAuthServer(config, deps = {}) {
394
394
  const next = refresh.rotate(form.refresh_token ?? '', now());
395
395
  if (!next)
396
396
  return json(res, 400, { error: 'invalid_grant', message: 'unknown or rotated refresh token' });
397
- const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
397
+ const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: next.sub });
398
398
  log('token issued grant=refresh_token');
399
- return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next, scope: 'mcp:use' });
399
+ return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next.token, scope: 'mcp:use' });
400
400
  }
401
401
  return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
402
402
  };
@@ -446,8 +446,8 @@ export function buildAuthServer(config, deps = {}) {
446
446
  return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'missing bearer token' }) };
447
447
  }
448
448
  try {
449
- await verifyAccessToken(header.slice(7), base, secret);
450
- return { ok: true };
449
+ const claims = await verifyAccessToken(header.slice(7), base, secret);
450
+ return { ok: true, sub: claims.sub };
451
451
  }
452
452
  catch {
453
453
  return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
@@ -9,9 +9,8 @@ import * as readline from 'node:readline';
9
9
  import path from 'node:path';
10
10
  import { encryptToken, decryptToken } from './token-store.js';
11
11
  import { atomicWriteFileSync, atomicWriteWithLock } from './fs-atomic.js';
12
- import { configFilePath, CONFIG_VERSION } from './config-file.js';
12
+ import { ALIAS_RE, configFilePath, CONFIG_VERSION } from './config-file.js';
13
13
  import { getTokenDir } from './accounts.js';
14
- const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
15
14
  function resolveDeps(deps) {
16
15
  return {
17
16
  configPath: deps.configPath ?? configFilePath(),
@@ -1,6 +1,7 @@
1
1
  import { ADMIN_SCOPES, BUNDLE_CATALOG } from './scope-catalog.js';
2
2
  import { BASE_SCOPES, resolveScopesForAccount } from './auth.js';
3
3
  import { readToken } from './token-store.js';
4
+ import { reauthHint } from './reauth-hint.js';
4
5
  // Reverse index scope -> bundles offering it, narrowest bundle first (fewest
5
6
  // scopes) so the remediation names the smallest grant. BASE_SCOPES map to the
6
7
  // pseudo-bundle "base" (outside a profile only via includesBase:false).
@@ -74,7 +75,7 @@ export function scopeHint(scope, c, alias) {
74
75
  case 'requestable_not_granted':
75
76
  return {
76
77
  hint: `Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
77
- `Re-grant: npx mcp-google-multi auth --account ${alias}`,
78
+ reauthHint(alias),
78
79
  retriable: true,
79
80
  };
80
81
  case 'not_requestable':
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { getAccountSet, invalidateAccountSet } from '../accounts.js';
3
- import { mutateConfigFile } from '../config-file.js';
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
6
  import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
@@ -9,13 +9,6 @@ import { coerceBoolean } from './_coerce.js';
9
9
  import { safeMessage, stringifyEnvelope } from './_errors.js';
10
10
  import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
11
11
  import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
12
- // B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
13
- // interactive account management on the mutable config.json registry so a
14
- // running server can add an account without the deployer editing env + running
15
- // a CLI. Google consent reuses the loopback flow (leg C); the HTTP-transport
16
- // consent path (${BASE}/authorize) is owned by the OAuth AS and lands with that
17
- // cluster. account_add over http is gated behind it.
18
- const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
19
12
  // The common optional bundles get their own checkbox (MCP elicitation has no
20
13
  // multi-select, so a boolean per bundle IS the checklist); the long tail stays
21
14
  // a comma-separated field for power users, and `allBundles` grants every one.
@@ -1,3 +1,4 @@
1
+ import { reauthHint } from '../reauth-hint.js';
1
2
  import * as fs from 'node:fs';
2
3
  import { getAccountSet, refreshAccountSetIfStale } from '../accounts.js';
3
4
  import { buildScopesReport } from '../scope-observability.js';
@@ -35,7 +36,7 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
35
36
  status: 'missing',
36
37
  hint: legacy
37
38
  ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
38
- : `Run: npx mcp-google-multi auth --account ${alias}`,
39
+ : reauthHint(alias),
39
40
  },
40
41
  scopes: noScopes,
41
42
  };
@@ -69,7 +70,7 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
69
70
  status,
70
71
  expiryDate: expiry !== undefined ? new Date(expiry).toISOString() : undefined,
71
72
  hint: status === 'needs_reauth'
72
- ? `Run: npx mcp-google-multi auth --account ${alias}`
73
+ ? reauthHint(alias)
73
74
  : missing.length > 0
74
75
  ? 'Re-auth to grant the missing scopes'
75
76
  : undefined,
@@ -86,6 +86,11 @@ export interface InitOptions {
86
86
  now?: () => number;
87
87
  monotonic?: () => number;
88
88
  log?: (line: string) => void;
89
+ /** Default true. The retry and bigram chains correlate CONSECUTIVE calls,
90
+ * which is only meaningful when one caller owns the whole process; a host
91
+ * serving several callers sets false so their sequences never cross-link.
92
+ * DayAgg shape is unchanged (retries/bigrams stay empty maps). */
93
+ sequencingEnabled?: boolean;
89
94
  }
90
95
  /** Deep-add b into a for the DayAgg shape (numbers add, maps union). */
91
96
  export declare function mergeDay(a: DayAgg, b: DayAgg): DayAgg;
@@ -108,6 +113,7 @@ export declare class Metrics {
108
113
  private dirty;
109
114
  private failures;
110
115
  private disabled;
116
+ private readonly sequencing;
111
117
  constructor(opts: InitOptions);
112
118
  private day;
113
119
  private initStorage;
@@ -221,7 +221,9 @@ export class Metrics {
221
221
  dirty = false;
222
222
  failures = 0;
223
223
  disabled = false;
224
+ sequencing;
224
225
  constructor(opts) {
226
+ this.sequencing = opts.sequencingEnabled !== false;
225
227
  this.dir = opts.dir ?? path.join(stateDir(opts.env), METRICS_DIR_NAME);
226
228
  this.aggDir = path.join(this.dir, 'agg');
227
229
  this.eventsFile = path.join(this.dir, 'events.jsonl');
@@ -357,22 +359,26 @@ export class Metrics {
357
359
  f.calls += 1;
358
360
  addInto(f, fan);
359
361
  }
360
- // retry self-correction chain (spec section 1): in-memory only.
362
+ // retry self-correction chain (spec section 1): in-memory only. Both
363
+ // chains link consecutive calls, so they only run when one caller owns
364
+ // the process (sequencingEnabled).
361
365
  const nowM = this.mono();
362
- const prev = this.lastError.get(entry.name);
363
- if (prev && nowM - prev.at <= RETRY_WINDOW_MS) {
364
- const rr = (this.deltas.retries[prev.slug] ??= { hintedOk: 0, hintedFail: 0, unhintedOk: 0, unhintedFail: 0 });
365
- const key = `${prev.hinted ? 'hinted' : 'unhinted'}${ok ? 'Ok' : 'Fail'}`;
366
- rr[key] += 1;
367
- this.lastError.delete(entry.name);
368
- }
369
- if (!ok && slug)
370
- this.lastError.set(entry.name, { slug, hinted: hinted === true, at: nowM });
371
- // bigrams: ordered pairs within the process, 5-minute gap breaks the chain.
372
- if (this.lastTool && nowM - this.lastTool.at <= BIGRAM_GAP_MS) {
373
- addInto(this.deltas.bigrams, `${this.lastTool.name}>${entry.name}`);
366
+ if (this.sequencing) {
367
+ const prev = this.lastError.get(entry.name);
368
+ if (prev && nowM - prev.at <= RETRY_WINDOW_MS) {
369
+ const rr = (this.deltas.retries[prev.slug] ??= { hintedOk: 0, hintedFail: 0, unhintedOk: 0, unhintedFail: 0 });
370
+ const key = `${prev.hinted ? 'hinted' : 'unhinted'}${ok ? 'Ok' : 'Fail'}`;
371
+ rr[key] += 1;
372
+ this.lastError.delete(entry.name);
373
+ }
374
+ if (!ok && slug)
375
+ this.lastError.set(entry.name, { slug, hinted: hinted === true, at: nowM });
376
+ // bigrams: ordered pairs within the process, 5-minute gap breaks the chain.
377
+ if (this.lastTool && nowM - this.lastTool.at <= BIGRAM_GAP_MS) {
378
+ addInto(this.deltas.bigrams, `${this.lastTool.name}>${entry.name}`);
379
+ }
380
+ this.lastTool = { name: entry.name, at: nowM };
374
381
  }
375
- this.lastTool = { name: entry.name, at: nowM };
376
382
  const event = {
377
383
  ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
378
384
  boot: this.bootId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.36",
3
+ "version": "6.0.0-alpha.38",
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",