mcp-google-multi 6.0.0-alpha.66 → 6.0.0-alpha.68

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.
@@ -85,6 +85,7 @@ export declare class HttpTransportHost {
85
85
  private readonly depth;
86
86
  private readonly sockets;
87
87
  private readonly pending;
88
+ private readonly served;
88
89
  private readonly handling;
89
90
  private closing;
90
91
  private graceful?;
@@ -70,6 +70,10 @@ const MAX_GRACE_MS = 2 ** 31 - 1;
70
70
  function endAfterFlush(socket) {
71
71
  if (socket.destroyed || socket.writableEnded)
72
72
  return;
73
+ // Node arms a keep-alive timer when a response finishes and destroys the
74
+ // socket when it fires, which would cut a slow reader's unread tail just as
75
+ // an early destroy does. Only the client's FIN or the grace ends it now.
76
+ socket.setTimeout(0);
73
77
  socket.end();
74
78
  }
75
79
  // A request that arrives while closing is never answered, so Node keeps it
@@ -109,6 +113,9 @@ export class HttpTransportHost {
109
113
  // every handle() that has not settled.
110
114
  sockets = new Set();
111
115
  pending = new Map();
116
+ // Sockets that have accepted a request: nothing pending means the last
117
+ // response finished writing, but its tail can still sit in kernel buffers.
118
+ served = new WeakSet();
112
119
  handling = new Set();
113
120
  closing = false;
114
121
  graceful;
@@ -143,6 +150,7 @@ export class HttpTransportHost {
143
150
  stopReading(socket);
144
151
  return;
145
152
  }
153
+ this.served.add(socket);
146
154
  const entry = this.pending.get(socket);
147
155
  if (entry) {
148
156
  entry.count++;
@@ -232,7 +240,8 @@ export class HttpTransportHost {
232
240
  this.httpServer = undefined;
233
241
  return;
234
242
  }
235
- // Starting over would destroy every socket in its lingering close.
243
+ // Starting over would arm a second grace, and a shorter one would cut
244
+ // what the first still lets finish.
236
245
  this.graceful ??= this.closeGracefully(s, graceMs).finally(() => (this.graceful = undefined));
237
246
  return this.graceful;
238
247
  }
@@ -249,15 +258,19 @@ export class HttpTransportHost {
249
258
  // after the last of them: that one says so if it has not started, and the
250
259
  // settle hook in start() ends the socket once it finishes. Node itself
251
260
  // destroys a socket once a Connection: close response is flushed, which
252
- // resets it just as endAfterFlush explains, so that end lingers too.
261
+ // resets it just as endAfterFlush explains, so that end lingers too. A
262
+ // socket with nothing pending may still be delivering the tail of its
263
+ // last response, so only one that never accepted a request is destroyed.
253
264
  for (const socket of this.sockets) {
254
265
  const entry = this.pending.get(socket);
255
- if (!entry) {
266
+ if (!entry && !this.served.has(socket)) {
256
267
  socket.destroy();
257
268
  continue;
258
269
  }
259
270
  socket.destroySoon = () => endAfterFlush(socket);
260
- if (!entry.last.headersSent)
271
+ if (!entry)
272
+ endAfterFlush(socket);
273
+ else if (!entry.last.headersSent)
261
274
  entry.last.setHeader('Connection', 'close');
262
275
  }
263
276
  let timer;
@@ -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
package/dist/oauth-as.js CHANGED
@@ -200,10 +200,10 @@ export function buildAuthServer(config, deps = {}) {
200
200
  const now = deps.now ?? (() => Date.now());
201
201
  const log = deps.log ?? (() => undefined);
202
202
  // Silently dropping a lifetime the caller asked for would leave sessions unlimited.
203
- if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined)) {
204
- throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec and refreshIdleSec configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
203
+ if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined || config.refreshMaxFamiliesPerSub !== undefined)) {
204
+ throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec, refreshIdleSec and refreshMaxFamiliesPerSub configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
205
205
  }
206
- const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, log });
206
+ const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, maxFamiliesPerSub: config.refreshMaxFamiliesPerSub, log });
207
207
  const nowSec = () => Math.floor(now() / 1000);
208
208
  // A full guard is refused at whatever rate the caller likes, so its line is
209
209
  // written at most once a minute (or at once after the clock stepped back).
Binary file
@@ -86,11 +86,26 @@ 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). */
89
+ /** Default true. Three chains correlate CONSECUTIVE calls, which is only
90
+ * meaningful when one caller owns the whole process: the retry and bigram
91
+ * chains, and pendingMid (the escape hatch's resolved methodId, attached to
92
+ * the next google_api_call event's `mid`). A host serving several callers
93
+ * sets false so none of the three ever cross-link a call between tenants.
94
+ * DayAgg shape is unchanged (retries/bigrams stay empty maps);
95
+ * escape.methods still counts every resolved call either way. */
93
96
  sequencingEnabled?: boolean;
97
+ /** Default true. When false, no events.jsonl / events.1.jsonl is ever
98
+ * created or appended to; only agg/ exists. A host serving several callers
99
+ * sets false: each event line carries a minute timestamp, the boot id and a
100
+ * per-process sequence number, which can be joined against a per-caller log
101
+ * such as an audit trail; the day aggregate keeps the same counters without
102
+ * order. */
103
+ eventLog?: boolean;
104
+ /** Default none. A host's own closed slug set, unioned with
105
+ * KNOWN_ERROR_SLUGS for this instance only; each entry must match
106
+ * /^[A-Za-z][A-Za-z0-9_]{0,63}$/ or it is dropped silently. Absent,
107
+ * behavior is byte-identical to today. */
108
+ extraErrorSlugs?: readonly string[];
94
109
  }
95
110
  /** Deep-add b into a for the DayAgg shape (numbers add, maps union). */
96
111
  export declare function mergeDay(a: DayAgg, b: DayAgg): DayAgg;
@@ -114,6 +129,8 @@ export declare class Metrics {
114
129
  private failures;
115
130
  private disabled;
116
131
  private readonly sequencing;
132
+ private readonly eventLog;
133
+ private readonly errorSlugs;
117
134
  constructor(opts: InitOptions);
118
135
  private day;
119
136
  private initStorage;
@@ -33,6 +33,7 @@ const TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
33
33
  // Declared argument keys are camelCase identifiers, some dotted
34
34
  // (groupKey.id, debugOptions.enableDebugging).
35
35
  const ARG_KEY_RE = /^[A-Za-z][A-Za-z0-9_.]{0,63}$/;
36
+ const EXTRA_ERROR_SLUG_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
36
37
  /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
37
38
  * by a set-equality grep test). Anything outside buckets to `other`. */
38
39
  export const KNOWN_ERROR_SLUGS = new Set([
@@ -222,8 +223,13 @@ export class Metrics {
222
223
  failures = 0;
223
224
  disabled = false;
224
225
  sequencing;
226
+ eventLog;
227
+ errorSlugs;
225
228
  constructor(opts) {
226
229
  this.sequencing = opts.sequencingEnabled !== false;
230
+ this.eventLog = opts.eventLog !== false;
231
+ const extras = (opts.extraErrorSlugs ?? []).filter((s) => EXTRA_ERROR_SLUG_RE.test(s));
232
+ this.errorSlugs = extras.length ? new Set([...KNOWN_ERROR_SLUGS, ...extras]) : KNOWN_ERROR_SLUGS;
227
233
  this.dir = opts.dir ?? path.join(stateDir(opts.env), METRICS_DIR_NAME);
228
234
  this.aggDir = path.join(this.dir, 'agg');
229
235
  this.eventsFile = path.join(this.dir, 'events.jsonl');
@@ -252,7 +258,8 @@ export class Metrics {
252
258
  }
253
259
  }
254
260
  this.pruneAggs();
255
- this.pruneEventAge(this.eventsFile);
261
+ if (this.eventLog)
262
+ this.pruneEventAge(this.eventsFile);
256
263
  }
257
264
  catch (e) {
258
265
  this.log(`local usage metrics: init failed (${e.message}); disabled for this process\n`);
@@ -320,7 +327,7 @@ export class Metrics {
320
327
  if (typeof first === 'string' && first.length < 65_536) {
321
328
  const env = JSON.parse(first);
322
329
  if (typeof env.error === 'string')
323
- slug = KNOWN_ERROR_SLUGS.has(env.error) ? env.error : 'other';
330
+ slug = this.errorSlugs.has(env.error) ? env.error : 'other';
324
331
  hinted = typeof env.hint === 'string' && env.hint.length > 0;
325
332
  }
326
333
  }
@@ -379,29 +386,31 @@ export class Metrics {
379
386
  }
380
387
  this.lastTool = { name: entry.name, at: nowM };
381
388
  }
382
- const event = {
383
- ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
384
- boot: this.bootId,
385
- seq: this.seq++,
386
- tool: entry.name,
387
- ok,
388
- ms: Math.round(ms),
389
- chars: charsBucket(chars),
390
- src: 'handler',
391
- };
392
- if (slug)
393
- event.err = slug;
394
- if (hinted !== undefined)
395
- event.hint = hinted;
396
- if (fan)
397
- event.fan = fan;
398
- // mid: set mid-dispatch by recordEscapeMethod, attached to the escape
399
- // call's own event line (per-event field, never a synthetic event).
400
- if (entry.name === 'google_api_call' && this.pendingMid) {
401
- event.mid = this.pendingMid;
402
- this.pendingMid = undefined;
389
+ if (this.eventLog) {
390
+ const event = {
391
+ ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
392
+ boot: this.bootId,
393
+ seq: this.seq++,
394
+ tool: entry.name,
395
+ ok,
396
+ ms: Math.round(ms),
397
+ chars: charsBucket(chars),
398
+ src: 'handler',
399
+ };
400
+ if (slug)
401
+ event.err = slug;
402
+ if (hinted !== undefined)
403
+ event.hint = hinted;
404
+ if (fan)
405
+ event.fan = fan;
406
+ // mid: set mid-dispatch by recordEscapeMethod, attached to the escape
407
+ // call's own event line (per-event field, never a synthetic event).
408
+ if (this.sequencing && entry.name === 'google_api_call' && this.pendingMid) {
409
+ event.mid = this.pendingMid;
410
+ this.pendingMid = undefined;
411
+ }
412
+ this.events.push(JSON.stringify(event));
403
413
  }
404
- this.events.push(JSON.stringify(event));
405
414
  this.dirty = true;
406
415
  }
407
416
  /** Escape hatch: only the RESOLVED methodId (double-gated) or a miss. */
@@ -413,7 +422,8 @@ export class Metrics {
413
422
  }
414
423
  else if (idOrNull.length <= METHOD_ID_MAX && METHOD_ID_RE.test(idOrNull)) {
415
424
  addInto(this.deltas.escape.methods, idOrNull);
416
- this.pendingMid = idOrNull;
425
+ if (this.sequencing)
426
+ this.pendingMid = idOrNull;
417
427
  }
418
428
  this.dirty = true;
419
429
  }
@@ -481,12 +491,14 @@ export class Metrics {
481
491
  else {
482
492
  addInto(this.deltas.rpc, RPC_CODES.has(kind) ? `rpc_error_${kind}` : 'rpc_error_other');
483
493
  }
484
- this.events.push(JSON.stringify({
485
- ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
486
- boot: this.bootId, seq: this.seq++,
487
- tool: tool && TOOL_NAME_RE.test(tool) ? tool : 'other',
488
- ok: false, ms: 0, chars: 'le64', src: 'rpc',
489
- }));
494
+ if (this.eventLog) {
495
+ this.events.push(JSON.stringify({
496
+ ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
497
+ boot: this.bootId, seq: this.seq++,
498
+ tool: tool && TOOL_NAME_RE.test(tool) ? tool : 'other',
499
+ ok: false, ms: 0, chars: 'le64', src: 'rpc',
500
+ }));
501
+ }
490
502
  this.dirty = true;
491
503
  }
492
504
  catch { /* never throws outward */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.66",
3
+ "version": "6.0.0-alpha.68",
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",
@@ -30,6 +30,8 @@
30
30
  "./write-control": "./dist/write-control.js",
31
31
  "./scope-catalog": "./dist/scope-catalog.js",
32
32
  "./auth": "./dist/auth.js",
33
+ "./usage-metrics": "./dist/usage-metrics.js",
34
+ "./metrics-tap": "./dist/metrics-tap.js",
33
35
  "./package.json": "./package.json"
34
36
  },
35
37
  "files": [