mcp-google-multi 6.0.0-alpha.24 → 6.0.0-alpha.26

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.
@@ -11,9 +11,9 @@ export declare function normalizeCallArguments(shape: ArgShape, args: Record<str
11
11
  args: Record<string, unknown>;
12
12
  renamed: [string, string][];
13
13
  };
14
- export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): JSONRPCMessage;
14
+ export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): JSONRPCMessage;
15
15
  /** Wrap a server-side transport so tools/call argument keys are normalized
16
16
  * before the SDK validates them. The Protocol assigns `onmessage` during
17
17
  * connect(); the interceptor lives in that setter, so the wrapper works
18
18
  * identically for stdio and (per-request, stateless) HTTP transports. */
19
- export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void): Transport;
19
+ export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): Transport;
@@ -36,7 +36,7 @@ export function normalizeCallArguments(shape, args) {
36
36
  }
37
37
  return { args: out ?? args, renamed };
38
38
  }
39
- export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`)) {
39
+ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`), onRename) {
40
40
  const m = msg;
41
41
  if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
42
42
  return msg;
@@ -51,6 +51,10 @@ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.writ
51
51
  return msg;
52
52
  // Key names only — argument VALUES never reach the log.
53
53
  log(`[args] ${m.params.name}: ${renamed.map(([f, t]) => `${f} -> ${t}`).join(', ')}`);
54
+ try {
55
+ onRename?.(m.params.name, renamed.length);
56
+ }
57
+ catch { /* observers never break dispatch */ }
54
58
  return {
55
59
  ...msg,
56
60
  params: { ...m.params, arguments: normalized },
@@ -60,7 +64,7 @@ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.writ
60
64
  * before the SDK validates them. The Protocol assigns `onmessage` during
61
65
  * connect(); the interceptor lives in that setter, so the wrapper works
62
66
  * identically for stdio and (per-request, stateless) HTTP transports. */
63
- export function withArgNormalization(transport, shapeFor, log) {
67
+ export function withArgNormalization(transport, shapeFor, log, onRename) {
64
68
  const wrapper = {
65
69
  start: () => transport.start(),
66
70
  send: (message, options) => transport.send(message, options),
@@ -70,7 +74,7 @@ export function withArgNormalization(transport, shapeFor, log) {
70
74
  get: () => transport.onmessage,
71
75
  set: (handler) => {
72
76
  transport.onmessage = handler
73
- ? (message, extra) => handler(normalizeMessage(message, shapeFor, log), extra)
77
+ ? (message, extra) => handler(normalizeMessage(message, shapeFor, log, onRename), extra)
74
78
  : undefined;
75
79
  },
76
80
  });
@@ -17,6 +17,7 @@ export interface ConfigFile {
17
17
  defaultAccount?: string;
18
18
  discovery?: 'lazy' | 'curated' | 'eager';
19
19
  toolsets?: string;
20
+ usageMetrics?: boolean;
20
21
  }
21
22
  export declare function failStartup(slug: string, message: string): never;
22
23
  export declare class ConfigFileError extends Error {
@@ -36,6 +36,10 @@ const configSchema = z.strictObject({
36
36
  defaultAccount: z.string().optional(),
37
37
  discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
38
38
  toolsets: z.string().optional(),
39
+ // Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
40
+ // a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
41
+ // (strictObject); remove the key first.
42
+ usageMetrics: z.boolean().optional(),
39
43
  });
40
44
  export function failStartup(slug, message) {
41
45
  process.stderr.write(`${slug}: ${message}\n`);
package/dist/doctor.d.ts CHANGED
@@ -53,6 +53,9 @@ export interface HttpProbeResult {
53
53
  }
54
54
  /** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
55
55
  export declare function apiEnableLink(api: string): string;
56
+ /** One line, state AND source, so the people being measured can see both
57
+ * here and in `diagnose` (metrics spec section 2). Read-only. */
58
+ export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
56
59
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
57
60
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
58
61
  export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
package/dist/doctor.js CHANGED
@@ -5,7 +5,9 @@ import { getAccountSet } from './accounts.js';
5
5
  import { deriveAccountHealth } from './tools/accounts-tool.js';
6
6
  import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
7
7
  import { hasToken } from './token-store.js';
8
- import { configDir } from './config-file.js';
8
+ import { configDir, loadConfigFile } from './config-file.js';
9
+ import { envValueSource } from './env-load.js';
10
+ import { describeMetricsDir, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
9
11
  import { probeApiEnablement } from './api-probe.js';
10
12
  import { resolveHttpConfig, HttpConfigError } from './http-config.js';
11
13
  import { parseOwnerEmails } from './http-transport.js';
@@ -114,8 +116,24 @@ function sectionConfig(deps, set) {
114
116
  hint = (hint ? `${hint} ` : '') + `Move it: \`mv ${envFile} ${target}\`.`;
115
117
  }
116
118
  }
119
+ lines.push(usageMetricsStatusLine(deps.env));
117
120
  return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
118
121
  }
122
+ /** One line, state AND source, so the people being measured can see both
123
+ * here and in `diagnose` (metrics spec section 2). Read-only. */
124
+ export function usageMetricsStatusLine(env) {
125
+ const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
126
+ let configValue;
127
+ try {
128
+ configValue = loadConfigFile(undefined, 'throw')?.usageMetrics;
129
+ }
130
+ catch { /* invalid config is section 2's business, not this line's */ }
131
+ const state = resolveUsageMetrics(env, configValue, envSrc?.kind === 'file' ? envSrc.file : undefined);
132
+ if (!state.enabled)
133
+ return `local usage metrics: off ${sourceLabel(state.source)}`;
134
+ const d = describeMetricsDir(env);
135
+ return `local usage metrics: on ${sourceLabel(state.source)} -> ${d.dir} (${d.files} files, ${d.kb} KB)`;
136
+ }
119
137
  function sectionKeys(deps, aliases) {
120
138
  const provenance = deps.masterKeyProvenance();
121
139
  const tokensExist = deps.anyTokensExist(aliases);
@@ -9,3 +9,14 @@ export interface EnvLoadPaths {
9
9
  configDir?: string;
10
10
  }
11
11
  export declare function loadEnvFiles(paths?: EnvLoadPaths): EnvLoadResult;
12
+ /**
13
+ * Which layer supplied `key`: the real process env, or the first loaded .env
14
+ * file defining it (autoload never overwrites, so first wins). undefined when
15
+ * the key is unset. Powers self-announcing enablement sources (usage metrics).
16
+ */
17
+ export declare function envValueSource(key: string): {
18
+ kind: 'process';
19
+ } | {
20
+ kind: 'file';
21
+ file: string;
22
+ } | undefined;
package/dist/env-load.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -48,5 +48,27 @@ export function loadEnvFiles(paths = {}) {
48
48
  }
49
49
  for (const [k, v] of boot)
50
50
  process.env[k] = v;
51
+ lastLoad = { bootKeys: new Set(boot.keys()), loaded };
51
52
  return { loaded, searched: candidates };
52
53
  }
54
+ let lastLoad;
55
+ /**
56
+ * Which layer supplied `key`: the real process env, or the first loaded .env
57
+ * file defining it (autoload never overwrites, so first wins). undefined when
58
+ * the key is unset. Powers self-announcing enablement sources (usage metrics).
59
+ */
60
+ export function envValueSource(key) {
61
+ if (process.env[key] === undefined)
62
+ return undefined;
63
+ if (!lastLoad || lastLoad.bootKeys.has(key))
64
+ return { kind: 'process' };
65
+ const re = new RegExp(`^\\s*${key}\\s*=`, 'm');
66
+ for (const p of lastLoad.loaded) {
67
+ try {
68
+ if (re.test(readFileSync(p, 'utf-8')))
69
+ return { kind: 'file', file: p };
70
+ }
71
+ catch { /* file vanished since load: fall through */ }
72
+ }
73
+ return { kind: 'process' };
74
+ }
@@ -2,6 +2,7 @@ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { HttpConfig } from './http-config.js';
4
4
  import { type ArgShape } from './arg-normalize.js';
5
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
5
6
  export type AuthOutcome = {
6
7
  ok: true;
7
8
  } | {
@@ -31,6 +32,10 @@ export interface HttpHostOptions {
31
32
  dispatchTimeoutMs?: number;
32
33
  /** tools/call argument-key normalization lookup (arg-normalize.ts); absent = off. */
33
34
  argShapeFor?: (tool: string) => ArgShape | undefined;
35
+ /** Usage-metrics transport tap (metrics-tap.ts); absent = off. */
36
+ metricsTap?: (t: Transport) => Transport;
37
+ /** Usage-metrics argfix observer, forwarded into arg normalization. */
38
+ onArgRename?: (tool: string, renames: number) => void;
34
39
  }
35
40
  export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
36
41
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
@@ -183,7 +183,8 @@ export class HttpTransportHost {
183
183
  timer = setTimeout(() => resolve('timeout'), deadlineMs);
184
184
  timer.unref?.();
185
185
  });
186
- await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(transport, this.opts.argShapeFor, this.opts.log) : transport);
186
+ const tapped = this.opts.metricsTap ? this.opts.metricsTap(transport) : transport;
187
+ await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(tapped, this.opts.argShapeFor, this.opts.log, this.opts.onArgRename) : tapped);
187
188
  // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
188
189
  // race, an orphaned handler settling later must not surface as an unhandled
189
190
  // rejection — but a genuine dispatch error still propagates (rethrown below).
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
10
  import { GENERATED_SERVICES } from './tools/generated/index.js';
11
11
  import { GENERATED_GATES, SERVICES } from './services.js';
12
- import { ToolRegistry } from './registry.js';
12
+ import { ToolRegistry, resolveDiscoveryMode } from './registry.js';
13
13
  import { registerDiscoverTools } from './discover.js';
14
14
  import { registerEscapeTools } from './tools/google-api.js';
15
15
  import { registerAccountTools } from './tools/accounts-tool.js';
@@ -21,12 +21,15 @@ import { buildIdentityContext } from './identity.js';
21
21
  import { registerSetupPrompt } from './setup-prompt.js';
22
22
  import { applyNetTuning } from './net-tuning.js';
23
23
  import { argNormalizationEnabled, withArgNormalization } from './arg-normalize.js';
24
+ import { envValueSource } from './env-load.js';
25
+ import { loadConfigFile } from './config-file.js';
26
+ import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
24
27
  applyNetTuning();
25
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
29
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
27
- function buildRegistry(server, ctx, mode) {
30
+ function buildRegistry(server, ctx, mode, metrics = null) {
28
31
  const policy = ctx.policy;
29
- const registry = new ToolRegistry(server, policy, mode);
32
+ const registry = new ToolRegistry(server, policy, mode, metrics);
30
33
  const toolsets = getToolsets();
31
34
  if (toolsets !== 'all') {
32
35
  const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
@@ -183,15 +186,56 @@ async function main() {
183
186
  }
184
187
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
185
188
  const wantHttp = transportIncludesHttp(httpCfg.transport);
189
+ // Local usage metrics: fail-closed resolve, self-announcing source, null
190
+ // when off (no wrapper, no dir, nothing initializes). One instance per
191
+ // transport so `boots` keys stay honest under `both`.
192
+ const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
193
+ const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
194
+ if (metricsState.warning)
195
+ process.stderr.write(metricsState.warning);
196
+ const metricsInstances = [];
197
+ const initMetricsFor = (transport, mode) => {
198
+ const m = initUsageMetrics(metricsState, { version: pkg.version, mode, transport });
199
+ if (m) {
200
+ metricsInstances.push(m);
201
+ process.stderr.write(`local usage metrics: on ${sourceLabel(metricsState.source)} -> ${m.statusLine()}\n`);
202
+ }
203
+ return m;
204
+ };
205
+ if (!metricsState.enabled && metricsState.source.kind !== 'default') {
206
+ process.stderr.write(`local usage metrics: off ${sourceLabel(metricsState.source)}\n`);
207
+ }
208
+ const flushMetrics = () => { for (const m of metricsInstances)
209
+ m.shutdown(); };
210
+ process.on('exit', flushMetrics);
211
+ if (metricsState.enabled) {
212
+ for (const sig of ['SIGTERM', 'SIGINT']) {
213
+ process.once(sig, () => {
214
+ flushMetrics();
215
+ // stdio has no other signal handler; preserve terminate-on-signal.
216
+ if (!wantHttp)
217
+ process.exit(sig === 'SIGINT' ? 130 : 143);
218
+ });
219
+ }
220
+ }
186
221
  // Build one McpServer + registry per transport at boot (P1 / BV gap #4:
187
222
  // never rebuilt per request); `both` runs the two concurrently.
188
223
  if (wantStdio) {
189
224
  const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
190
- const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
225
+ const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
226
+ const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
191
227
  registry.installListHandler();
192
228
  registerSetupPrompt(server);
193
- const stdioTransport = new StdioServerTransport();
194
- await server.connect(argNormalizationEnabled() ? withArgNormalization(stdioTransport, (n) => registry.argShape(n)) : stdioTransport);
229
+ // Tap sits under arg normalization at the same wrap site; it only counts
230
+ // outbound JSON-RPC error frames (no handler ran) plus id->tool names.
231
+ let transport = new StdioServerTransport();
232
+ if (stdioMetrics) {
233
+ const { tapUsageMetrics } = await import('./metrics-tap.js');
234
+ transport = tapUsageMetrics(transport, stdioMetrics);
235
+ }
236
+ await server.connect(argNormalizationEnabled()
237
+ ? withArgNormalization(transport, (n) => registry.argShape(n), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined)
238
+ : transport);
195
239
  }
196
240
  if (wantHttp) {
197
241
  const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
@@ -208,7 +252,8 @@ async function main() {
208
252
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
209
253
  }
210
254
  const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
211
- const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated');
255
+ const httpMetrics = initMetricsFor('http', 'curated');
256
+ const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
212
257
  registry.installListHandler();
213
258
  registerSetupPrompt(httpServer);
214
259
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
@@ -274,6 +319,11 @@ async function main() {
274
319
  // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
275
320
  const { setHttpReauthBase } = await import('./reauth-hint.js');
276
321
  setHttpReauthBase(httpCfg.publicUrl);
322
+ let httpTap;
323
+ if (httpMetrics) {
324
+ const { tapUsageMetrics } = await import('./metrics-tap.js');
325
+ httpTap = (t) => tapUsageMetrics(t, httpMetrics);
326
+ }
277
327
  const host = new HttpTransportHost({
278
328
  server: httpServer,
279
329
  config: httpCfg,
@@ -283,6 +333,8 @@ async function main() {
283
333
  routes: authServer.routes,
284
334
  log: (l) => process.stderr.write(`[http] ${l}\n`),
285
335
  argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
336
+ metricsTap: httpTap,
337
+ onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
286
338
  });
287
339
  await host.start();
288
340
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
@@ -0,0 +1,3 @@
1
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
+ import type { Metrics } from './usage-metrics.js';
3
+ export declare function tapUsageMetrics(transport: Transport, metrics: Metrics): Transport;
@@ -0,0 +1,62 @@
1
+ const PENDING_CAP = 1_000;
2
+ export function tapUsageMetrics(transport, metrics) {
3
+ const pending = new Map();
4
+ const wrapper = {
5
+ start: () => transport.start(),
6
+ send: (message, options) => {
7
+ try {
8
+ const m = message;
9
+ if (m.id !== undefined) {
10
+ const tool = pending.get(m.id);
11
+ pending.delete(m.id);
12
+ if (m.error && typeof m.error.code === 'number') {
13
+ if (m.error.code === -32602) {
14
+ if (/unknown tool|not found/i.test(m.error.message ?? ''))
15
+ metrics.recordRpc('tool_not_found');
16
+ else
17
+ metrics.recordRpc('schema_validation', tool);
18
+ }
19
+ else {
20
+ metrics.recordRpc(m.error.code);
21
+ }
22
+ }
23
+ }
24
+ }
25
+ catch { /* the tap may never break the wire */ }
26
+ return transport.send(message, options);
27
+ },
28
+ close: () => transport.close(),
29
+ };
30
+ Object.defineProperty(wrapper, 'onmessage', {
31
+ get: () => transport.onmessage,
32
+ set: (handler) => {
33
+ transport.onmessage = handler
34
+ ? (message, extra) => {
35
+ try {
36
+ const m = message;
37
+ if (m.id !== undefined && m.method === 'tools/call' && typeof m.params?.name === 'string') {
38
+ if (pending.size >= PENDING_CAP)
39
+ pending.clear();
40
+ pending.set(m.id, m.params.name);
41
+ }
42
+ }
43
+ catch { /* never break the wire */ }
44
+ handler(message, extra);
45
+ }
46
+ : undefined;
47
+ },
48
+ });
49
+ for (const prop of ['onclose', 'onerror']) {
50
+ Object.defineProperty(wrapper, prop, {
51
+ get: () => transport[prop],
52
+ set: (v) => {
53
+ transport[prop] = v;
54
+ },
55
+ });
56
+ }
57
+ Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
58
+ if (transport.setProtocolVersion) {
59
+ wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
60
+ }
61
+ return wrapper;
62
+ }
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
4
  import type { ArgShape } from './arg-normalize.js';
5
+ import type { Metrics } from './usage-metrics.js';
5
6
  export type Cud = 'read' | 'create' | 'update' | 'delete';
6
7
  export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
7
8
  export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
@@ -34,6 +35,7 @@ export interface CatalogOperation {
34
35
  export declare function inferCud(name: string): Cud;
35
36
  export declare class ToolRegistry {
36
37
  private readonly server;
38
+ private readonly metrics;
37
39
  readonly tools: ToolEntry[];
38
40
  readonly policy: Policy;
39
41
  readonly registerTool: McpServer['registerTool'];
@@ -47,13 +49,15 @@ export declare class ToolRegistry {
47
49
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
48
50
  * lazy surface to curated without touching the configured mode. */
49
51
  private expanded;
50
- constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
52
+ constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
51
53
  registerMeta: McpServer['registerTool'];
52
54
  services(): string[];
53
55
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
54
56
  * normalization; the kind drives value coercion on renamed keys). */
55
57
  argShape(name: string): ArgShape | undefined;
56
58
  catalog(service: string, query?: string): CatalogOperation[];
59
+ /** The metrics recorder, for hook sites (escape hatch); null when off. */
60
+ get usageMetrics(): Metrics | null;
57
61
  /** Op-name vocabulary for a service, split by provenance so the capped
58
62
  * discover descriptions can list curated ops and only summarize the
59
63
  * generated long tail. */
package/dist/registry.js CHANGED
@@ -81,6 +81,7 @@ export function inferCud(name) {
81
81
  }
82
82
  export class ToolRegistry {
83
83
  server;
84
+ metrics;
84
85
  tools = [];
85
86
  policy;
86
87
  registerTool;
@@ -94,8 +95,9 @@ export class ToolRegistry {
94
95
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
95
96
  * lazy surface to curated without touching the configured mode. */
96
97
  expanded = false;
97
- constructor(server, policy, mode = resolveDiscoveryMode()) {
98
+ constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
98
99
  this.server = server;
100
+ this.metrics = metrics;
99
101
  this.policy = policy;
100
102
  this.mode = mode;
101
103
  this.registerTool = ((name, config, handler) => {
@@ -194,8 +196,21 @@ export class ToolRegistry {
194
196
  const finalHandler = this.compactOutput
195
197
  ? async (...args) => compactResult(await withDefault(...args))
196
198
  : withDefault;
199
+ // Usage metrics wrap OUTERMOST and only when enabled: off means no
200
+ // wrapper exists and the chain is byte-identical to the pre-metrics
201
+ // chain. Fan-out width is derived here (bucketed in the module) so the
202
+ // metrics module never imports fanout.
203
+ const instrumented = this.metrics
204
+ ? this.metrics.wrap({ name, service, meta: this.registeringMeta, generated: config.cud !== undefined }, finalHandler, (a) => {
205
+ const v = a?.account;
206
+ if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
207
+ return 1;
208
+ const sel = parseAccountSelector(v);
209
+ return sel.ok ? sel.aliases.length : 1;
210
+ })
211
+ : finalHandler;
197
212
  const { cud: _cud, ...sdkConfig } = config;
198
- return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, finalHandler);
213
+ return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, instrumented);
199
214
  });
200
215
  }
201
216
  registerMeta = ((name, config, handler) => {
@@ -237,6 +252,10 @@ export class ToolRegistry {
237
252
  cud: t.cud,
238
253
  }));
239
254
  }
255
+ /** The metrics recorder, for hook sites (escape hatch); null when off. */
256
+ get usageMetrics() {
257
+ return this.metrics;
258
+ }
240
259
  /** Op-name vocabulary for a service, split by provenance so the capped
241
260
  * discover descriptions can list curated ops and only summarize the
242
261
  * generated long tail. */
@@ -86,12 +86,15 @@ export function registerEscapeTools(registry, policy, deps = {}) {
86
86
  if (api) {
87
87
  requested = resolveApiAliases(api);
88
88
  if (!requested) {
89
+ registry.usageMetrics?.recordSearchApi(null);
89
90
  return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
90
91
  }
91
92
  const enabled = requested.filter(apiEnabled);
92
93
  if (enabled.length === 0)
93
94
  return toolsetDisabled(requested[0]);
94
95
  requested = enabled;
96
+ // Post-resolution SUPPORTED_APIS keys only (closed vocabulary).
97
+ registry.usageMetrics?.recordSearchApi(requested);
95
98
  }
96
99
  const apis = requested ?? enabledApis;
97
100
  const unavailable = [];
@@ -172,6 +175,7 @@ export function registerEscapeTools(registry, policy, deps = {}) {
172
175
  }
173
176
  }
174
177
  if (!method) {
178
+ registry.usageMetrics?.recordEscapeMethod(null);
175
179
  const near = nearestMethodIds(String(methodId), index);
176
180
  return jsonResult({
177
181
  error: 'unknown_method',
@@ -182,6 +186,8 @@ export function registerEscapeTools(registry, policy, deps = {}) {
182
186
  account,
183
187
  }, true);
184
188
  }
189
+ // The RESOLVED index id only, never the caller's methodId argument.
190
+ registry.usageMetrics?.recordEscapeMethod(method.id);
185
191
  const cud = cudFromMethod(method);
186
192
  const policyService = serviceForAlias(apiKey);
187
193
  const lastSegment = method.id.split('.').pop() ?? method.id;
@@ -0,0 +1,105 @@
1
+ export declare const METRICS_DIR_NAME = "metrics";
2
+ /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
3
+ * by a set-equality grep test). Anything outside buckets to `other`. */
4
+ export declare const KNOWN_ERROR_SLUGS: ReadonlySet<string>;
5
+ export declare function stateDir(env?: NodeJS.ProcessEnv): string;
6
+ export declare function latBucket(ms: number): string;
7
+ export declare function charsBucket(n: number): string;
8
+ export declare function fanBucket(width: number): string | undefined;
9
+ export type MetricsSource = {
10
+ kind: 'default';
11
+ } | {
12
+ kind: 'config';
13
+ } | {
14
+ kind: 'process-env';
15
+ } | {
16
+ kind: 'env-file';
17
+ file: string;
18
+ };
19
+ export interface UsageMetricsState {
20
+ enabled: boolean;
21
+ source: MetricsSource;
22
+ /** exactly one stderr warning on a malformed non-empty env value (fail-closed) */
23
+ warning?: string;
24
+ }
25
+ /**
26
+ * Fail-closed resolution (mirror of resolveDiscoveryMode's fail-open: here the
27
+ * safe state is off). Env wins over config; empty env value = unset, silent;
28
+ * any other value = one warning, off. `envFile` is the .env path that supplied
29
+ * the variable when it did not come from the real process env (attribution
30
+ * is computed by the caller; this module never reads env files).
31
+ */
32
+ export declare function resolveUsageMetrics(env: NodeJS.ProcessEnv, configValue: boolean | undefined, envFile?: string): UsageMetricsState;
33
+ export declare function sourceLabel(source: MetricsSource): string;
34
+ export interface ToolEntryInfo {
35
+ name: string;
36
+ service: string;
37
+ meta: boolean;
38
+ generated: boolean;
39
+ }
40
+ export interface InitOptions {
41
+ dir?: string;
42
+ version: string;
43
+ mode: string;
44
+ transport: string;
45
+ env?: NodeJS.ProcessEnv;
46
+ now?: () => number;
47
+ monotonic?: () => number;
48
+ log?: (line: string) => void;
49
+ }
50
+ export declare class Metrics {
51
+ private readonly dir;
52
+ private readonly aggDir;
53
+ private readonly eventsFile;
54
+ private readonly now;
55
+ private readonly mono;
56
+ private readonly log;
57
+ private readonly bootKey;
58
+ private readonly bootId;
59
+ private seq;
60
+ private deltas;
61
+ private events;
62
+ private lastError;
63
+ private lastTool;
64
+ private pendingMid;
65
+ private timer;
66
+ private dirty;
67
+ private failures;
68
+ private disabled;
69
+ constructor(opts: InitOptions);
70
+ private day;
71
+ private initStorage;
72
+ private pruneAggs;
73
+ private day2ms;
74
+ private pruneEventAge;
75
+ /** Outermost dispatch wrapper; a throw inside recording never affects the call. */
76
+ wrap<A extends unknown[], R>(entry: ToolEntryInfo, handler: (...args: A) => Promise<R> | R, widthOf?: (args: unknown) => number): (...args: A) => Promise<R>;
77
+ private record;
78
+ /** Escape hatch: only the RESOLVED methodId (double-gated) or a miss. */
79
+ recordEscapeMethod(idOrNull: string | null): void;
80
+ /** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
81
+ recordSearchApi(resolvedApis: string[] | null): void;
82
+ recordArgFix(tool: string, by?: number): void;
83
+ /** Protocol-level failures (no handler ran); wired by the transport tap. */
84
+ recordRpc(kind: 'schema_validation' | 'tool_not_found' | number, tool?: string): void;
85
+ private rolloverIfNeeded;
86
+ /** Merge deltas into the day file under lock; append buffered events. */
87
+ flush(): void;
88
+ private rotateIfNeeded;
89
+ /** Best-effort synchronous flush for shutdown paths. */
90
+ shutdown(): void;
91
+ /** For the doctor/diagnose status line. */
92
+ statusLine(): string;
93
+ }
94
+ /** Read-only description of the metrics dir for doctor/diagnose status lines
95
+ * (never creates anything; safe to call with the feature off). */
96
+ export declare function describeMetricsDir(env?: NodeJS.ProcessEnv): {
97
+ dir: string;
98
+ files: number;
99
+ kb: number;
100
+ };
101
+ /**
102
+ * null when off: no directory, no file, no timer, nothing initializes; setting
103
+ * USAGE_METRICS_PATH alone never enables anything and never creates anything.
104
+ */
105
+ export declare function initUsageMetrics(state: UsageMetricsState, opts: InitOptions): Metrics | null;
@@ -0,0 +1,567 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { performance } from 'node:perf_hooks';
6
+ import { withFileLock, atomicWriteFileSync } from './fs-atomic.js';
7
+ // Local usage metrics (metrics-feature-spec.md). Default OFF at every layer;
8
+ // when off, nothing here initializes and the dispatch chain carries no wrapper.
9
+ // Zero network egress is structural: this module's import set is a tested
10
+ // allowlist (fs/path/os/crypto/perf_hooks/fs-atomic), so egress code cannot
11
+ // even be expressed here. Every string persisted is a member of a server-owned
12
+ // closed vocabulary, matches a pinned shape regex, or is other/unknown_*/
13
+ // _overflow; free text is unrepresentable in the file format. No identity
14
+ // dimension of any kind: no aliases, hashes, emails, or exact account counts.
15
+ export const METRICS_DIR_NAME = 'metrics';
16
+ const FLUSH_MS = 60_000;
17
+ const RETRY_WINDOW_MS = 10 * 60_000;
18
+ const BIGRAM_GAP_MS = 5 * 60_000;
19
+ const BIGRAM_CAP = 1_500;
20
+ const ERR_SLUG_CAP = 32;
21
+ const ESCAPE_CAP = 500;
22
+ const EVENTS_ROTATE_BYTES = 4 * 1024 * 1024;
23
+ const RETENTION_DAYS = 180;
24
+ const WRITE_FAILURE_LIMIT = 3;
25
+ const LAT_BOUNDS = [100, 250, 500, 1000, 2500, 5000, 10000, 30000];
26
+ const CHARS_BOUNDS = [64, 256, 1024, 4096, 16384, 65536];
27
+ // methodId backstop shape gate: even a tampered discovery cache cannot turn
28
+ // arbitrary strings into recorded values (spec section 1).
29
+ const METHOD_ID_RE = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)+$/;
30
+ const METHOD_ID_MAX = 128;
31
+ const TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
32
+ /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
33
+ * by a set-equality grep test). Anything outside buckets to `other`. */
34
+ export const KNOWN_ERROR_SLUGS = new Set([
35
+ 'E_CIMD_INVALID', 'E_MCP_TOKEN_INVALID', 'E_NO_DEFAULT_ACCOUNT', 'ambiguous',
36
+ 'api_not_enabled', 'auth_required', 'binary', 'binary_unsupported',
37
+ 'diagnose_failed', 'discovery_unavailable', 'dispatch_timeout', 'forbidden',
38
+ 'insufficient_scope', 'internal', 'invalid_client', 'invalid_client_metadata',
39
+ 'invalid_grant', 'invalid_params', 'invalid_query', 'invalid_request',
40
+ 'invalid_scope', 'network_error', 'not_found', 'rate_limited',
41
+ 'reauth_required', 'too_large', 'toolset_disabled', 'unknown_api',
42
+ 'unknown_method', 'unsupported_grant_type', 'unsupported_type',
43
+ 'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
44
+ ]);
45
+ const RPC_CODES = new Set([-32700, -32600, -32601, -32603]);
46
+ export function stateDir(env = process.env) {
47
+ return path.join(env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'mcp-google-multi');
48
+ }
49
+ export function latBucket(ms) {
50
+ for (const b of LAT_BOUNDS)
51
+ if (ms <= b)
52
+ return `le${b}`;
53
+ return 'gt30000';
54
+ }
55
+ export function charsBucket(n) {
56
+ for (const b of CHARS_BOUNDS)
57
+ if (n <= b)
58
+ return `le${b}`;
59
+ return 'gt65536';
60
+ }
61
+ export function fanBucket(width) {
62
+ if (width <= 1)
63
+ return undefined;
64
+ if (width === 2)
65
+ return 'w2';
66
+ if (width <= 4)
67
+ return 'w3to4';
68
+ return 'w5plus';
69
+ }
70
+ const ON = new Set(['on', '1', 'true', 'yes']);
71
+ const OFF = new Set(['off', '0', 'false', 'no']);
72
+ /**
73
+ * Fail-closed resolution (mirror of resolveDiscoveryMode's fail-open: here the
74
+ * safe state is off). Env wins over config; empty env value = unset, silent;
75
+ * any other value = one warning, off. `envFile` is the .env path that supplied
76
+ * the variable when it did not come from the real process env (attribution
77
+ * is computed by the caller; this module never reads env files).
78
+ */
79
+ export function resolveUsageMetrics(env, configValue, envFile) {
80
+ const raw = env.GOOGLE_USAGE_METRICS;
81
+ if (raw !== undefined && raw.trim() !== '') {
82
+ const v = raw.trim().toLowerCase();
83
+ const source = envFile ? { kind: 'env-file', file: envFile } : { kind: 'process-env' };
84
+ if (ON.has(v))
85
+ return { enabled: true, source };
86
+ if (OFF.has(v))
87
+ return { enabled: false, source };
88
+ return {
89
+ enabled: false,
90
+ source: { kind: 'default' },
91
+ warning: `GOOGLE_USAGE_METRICS="${raw}" is not a valid value (on|1|true|yes / off|0|false|no); local usage metrics stay OFF\n`,
92
+ };
93
+ }
94
+ if (configValue === true)
95
+ return { enabled: true, source: { kind: 'config' } };
96
+ return { enabled: false, source: { kind: 'default' } };
97
+ }
98
+ export function sourceLabel(source) {
99
+ switch (source.kind) {
100
+ case 'config': return '(config)';
101
+ case 'process-env': return '(process env)';
102
+ case 'env-file': return `(env file: ${source.file})`;
103
+ default: return '(default)';
104
+ }
105
+ }
106
+ function emptyDay(day) {
107
+ return {
108
+ v: 1, day, boots: {}, node: process.version, calls: 0, tools: {},
109
+ hints: {}, retries: {},
110
+ escape: { methods: {}, _overflow: 0, unknown_method: 0, apis_searched: {}, unknown_api: 0 },
111
+ validation: {}, rpc: {}, bigrams: {},
112
+ };
113
+ }
114
+ function addInto(target, key, by = 1) {
115
+ target[key] = (target[key] ?? 0) + by;
116
+ }
117
+ /** Deep-add b into a for the DayAgg shape (numbers add, maps union). */
118
+ function mergeDay(a, b) {
119
+ const num = (x, y) => (x ?? 0) + (y ?? 0);
120
+ const map = (x = {}, y = {}) => {
121
+ const out = { ...x };
122
+ for (const [k, v] of Object.entries(y))
123
+ out[k] = (out[k] ?? 0) + v;
124
+ return out;
125
+ };
126
+ const out = emptyDay(a.day);
127
+ out.node = b.node || a.node;
128
+ out.boots = map(a.boots, b.boots);
129
+ out.calls = num(a.calls, b.calls);
130
+ const toolNames = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
131
+ for (const name of toolNames) {
132
+ const x = a.tools[name];
133
+ const y = b.tools[name];
134
+ const t = { n: num(x?.n, y?.n), err: map(x?.err, y?.err), hint: num(x?.hint, y?.hint), lat: map(x?.lat, y?.lat) };
135
+ if (x?.fanout || y?.fanout)
136
+ t.fanout = map(x?.fanout, y?.fanout);
137
+ const argfix = num(x?.argfix, y?.argfix);
138
+ if (argfix > 0)
139
+ t.argfix = argfix;
140
+ out.tools[name] = t;
141
+ }
142
+ const slugSet = new Set([...Object.keys(a.hints), ...Object.keys(b.hints)]);
143
+ for (const s of slugSet) {
144
+ out.hints[s] = {
145
+ hinted: num(a.hints[s]?.hinted, b.hints[s]?.hinted),
146
+ unhinted: num(a.hints[s]?.unhinted, b.hints[s]?.unhinted),
147
+ };
148
+ }
149
+ const retrySet = new Set([...Object.keys(a.retries), ...Object.keys(b.retries)]);
150
+ for (const s of retrySet) {
151
+ out.retries[s] = {
152
+ hintedOk: num(a.retries[s]?.hintedOk, b.retries[s]?.hintedOk),
153
+ hintedFail: num(a.retries[s]?.hintedFail, b.retries[s]?.hintedFail),
154
+ unhintedOk: num(a.retries[s]?.unhintedOk, b.retries[s]?.unhintedOk),
155
+ unhintedFail: num(a.retries[s]?.unhintedFail, b.retries[s]?.unhintedFail),
156
+ };
157
+ }
158
+ out.escape = {
159
+ methods: map(a.escape.methods, b.escape.methods),
160
+ _overflow: num(a.escape._overflow, b.escape._overflow),
161
+ unknown_method: num(a.escape.unknown_method, b.escape.unknown_method),
162
+ apis_searched: map(a.escape.apis_searched, b.escape.apis_searched),
163
+ unknown_api: num(a.escape.unknown_api, b.escape.unknown_api),
164
+ };
165
+ out.validation = map(a.validation, b.validation);
166
+ out.rpc = map(a.rpc, b.rpc);
167
+ out.bigrams = map(a.bigrams, b.bigrams);
168
+ return out;
169
+ }
170
+ /** Cap open maps at write time (the file may never exceed the caps). */
171
+ function applyCaps(day) {
172
+ const cap = (m, limit, overflowInto) => {
173
+ const entries = Object.entries(m);
174
+ if (entries.length <= limit)
175
+ return m;
176
+ entries.sort((x, y) => y[1] - x[1]);
177
+ const kept = Object.fromEntries(entries.slice(0, limit));
178
+ const dropped = entries.slice(limit).reduce((s, [, v]) => s + v, 0);
179
+ if (overflowInto)
180
+ overflowInto(dropped);
181
+ else
182
+ kept.other = (kept.other ?? 0) + dropped;
183
+ return kept;
184
+ };
185
+ for (const t of Object.values(day.tools))
186
+ t.err = cap(t.err, ERR_SLUG_CAP);
187
+ day.escape.methods = cap(day.escape.methods, ESCAPE_CAP, (n) => { day.escape._overflow += n; });
188
+ day.bigrams = cap(day.bigrams, BIGRAM_CAP, (n) => { day.bigrams._overflow = (day.bigrams._overflow ?? 0) + n; });
189
+ return day;
190
+ }
191
+ export class Metrics {
192
+ dir;
193
+ aggDir;
194
+ eventsFile;
195
+ now;
196
+ mono;
197
+ log;
198
+ bootKey;
199
+ bootId = randomBytes(3).toString('hex');
200
+ seq = 0;
201
+ deltas;
202
+ events = [];
203
+ lastError = new Map();
204
+ lastTool;
205
+ pendingMid;
206
+ timer;
207
+ dirty = false;
208
+ failures = 0;
209
+ disabled = false;
210
+ constructor(opts) {
211
+ this.dir = opts.dir ?? path.join(stateDir(opts.env), METRICS_DIR_NAME);
212
+ this.aggDir = path.join(this.dir, 'agg');
213
+ this.eventsFile = path.join(this.dir, 'events.jsonl');
214
+ this.now = opts.now ?? Date.now;
215
+ this.mono = opts.monotonic ?? (() => performance.now());
216
+ this.log = opts.log ?? ((l) => process.stderr.write(l));
217
+ this.bootKey = `${opts.version}/${opts.mode}/${opts.transport}`;
218
+ this.deltas = emptyDay(this.day());
219
+ this.deltas.boots[this.bootKey] = 1;
220
+ this.dirty = true;
221
+ this.initStorage();
222
+ this.timer = setInterval(() => this.flush(), FLUSH_MS);
223
+ this.timer.unref?.();
224
+ }
225
+ day() {
226
+ return new Date(this.now()).toISOString().slice(0, 10);
227
+ }
228
+ initStorage() {
229
+ try {
230
+ const existed = fs.existsSync(this.dir);
231
+ fs.mkdirSync(this.aggDir, { recursive: true, mode: 0o700 });
232
+ if (existed) {
233
+ const mode = fs.statSync(this.dir).mode & 0o777;
234
+ if ((mode & 0o077) !== 0) {
235
+ this.log(`local usage metrics: ${this.dir} has mode ${mode.toString(8)} (wider than 0700); not changing it, but the files record activity\n`);
236
+ }
237
+ }
238
+ this.pruneAggs();
239
+ this.pruneEventAge(this.eventsFile);
240
+ }
241
+ catch (e) {
242
+ this.log(`local usage metrics: init failed (${e.message}); disabled for this process\n`);
243
+ this.disabled = true;
244
+ }
245
+ }
246
+ pruneAggs() {
247
+ const cutoff = this.day2ms(this.day()) - RETENTION_DAYS * 86_400_000;
248
+ for (const f of fs.readdirSync(this.aggDir)) {
249
+ const m = f.match(/^(\d{4}-\d{2}-\d{2})\.json$/);
250
+ if (m && this.day2ms(m[1]) < cutoff) {
251
+ try {
252
+ fs.rmSync(path.join(this.aggDir, f));
253
+ }
254
+ catch { /* prune is best-effort */ }
255
+ }
256
+ }
257
+ }
258
+ day2ms(day) {
259
+ return Date.parse(`${day}T00:00:00Z`);
260
+ }
261
+ pruneEventAge(file) {
262
+ if (!fs.existsSync(file))
263
+ return;
264
+ const cutoff = this.now() - RETENTION_DAYS * 86_400_000;
265
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
266
+ const kept = lines.filter((l) => {
267
+ if (!l.trim())
268
+ return false;
269
+ try {
270
+ const ts = JSON.parse(l).ts;
271
+ return typeof ts === 'string' && Date.parse(ts) >= cutoff;
272
+ }
273
+ catch {
274
+ return false; // torn or corrupt line: drop
275
+ }
276
+ });
277
+ if (kept.length !== lines.filter((l) => l.trim()).length) {
278
+ atomicWriteFileSync(file, kept.join('\n') + (kept.length ? '\n' : ''));
279
+ }
280
+ }
281
+ /** Outermost dispatch wrapper; a throw inside recording never affects the call. */
282
+ wrap(entry, handler, widthOf) {
283
+ return async (...args) => {
284
+ const started = this.mono();
285
+ const result = await handler(...args);
286
+ try {
287
+ this.record(entry, args[0], result, this.mono() - started, widthOf);
288
+ }
289
+ catch { /* metrics may never fail a tool call */ }
290
+ return result;
291
+ };
292
+ }
293
+ record(entry, firstArg, result, ms, widthOf) {
294
+ if (this.disabled)
295
+ return;
296
+ this.rolloverIfNeeded();
297
+ const r = result;
298
+ const ok = !r?.isError;
299
+ let slug;
300
+ let hinted;
301
+ if (!ok) {
302
+ try {
303
+ const first = r?.content?.[0]?.text;
304
+ if (typeof first === 'string' && first.length < 65_536) {
305
+ const env = JSON.parse(first);
306
+ if (typeof env.error === 'string')
307
+ slug = KNOWN_ERROR_SLUGS.has(env.error) ? env.error : 'other';
308
+ hinted = typeof env.hint === 'string' && env.hint.length > 0;
309
+ }
310
+ }
311
+ catch {
312
+ slug = 'other';
313
+ }
314
+ slug ??= 'other';
315
+ }
316
+ const chars = (r?.content ?? []).reduce((s, c) => s + (typeof c.text === 'string' ? c.text.length : 0), 0);
317
+ let width = 1;
318
+ if (widthOf) {
319
+ try {
320
+ width = widthOf(firstArg);
321
+ }
322
+ catch {
323
+ width = 1;
324
+ }
325
+ }
326
+ const fan = fanBucket(width);
327
+ const t = (this.deltas.tools[entry.name] ??= { n: 0, err: {}, hint: 0, lat: {} });
328
+ t.n += 1;
329
+ this.deltas.calls += 1;
330
+ addInto(t.lat, latBucket(ms));
331
+ if (slug) {
332
+ addInto(t.err, slug);
333
+ if (hinted)
334
+ t.hint += 1;
335
+ const h = (this.deltas.hints[slug] ??= { hinted: 0, unhinted: 0 });
336
+ if (hinted)
337
+ h.hinted += 1;
338
+ else
339
+ h.unhinted += 1;
340
+ }
341
+ if (fan) {
342
+ const f = (t.fanout ??= { calls: 0 });
343
+ f.calls += 1;
344
+ addInto(f, fan);
345
+ }
346
+ // retry self-correction chain (spec section 1): in-memory only.
347
+ const nowM = this.mono();
348
+ const prev = this.lastError.get(entry.name);
349
+ if (prev && nowM - prev.at <= RETRY_WINDOW_MS) {
350
+ const rr = (this.deltas.retries[prev.slug] ??= { hintedOk: 0, hintedFail: 0, unhintedOk: 0, unhintedFail: 0 });
351
+ const key = `${prev.hinted ? 'hinted' : 'unhinted'}${ok ? 'Ok' : 'Fail'}`;
352
+ rr[key] += 1;
353
+ this.lastError.delete(entry.name);
354
+ }
355
+ if (!ok && slug)
356
+ this.lastError.set(entry.name, { slug, hinted: hinted === true, at: nowM });
357
+ // bigrams: ordered pairs within the process, 5-minute gap breaks the chain.
358
+ if (this.lastTool && nowM - this.lastTool.at <= BIGRAM_GAP_MS) {
359
+ addInto(this.deltas.bigrams, `${this.lastTool.name}>${entry.name}`);
360
+ }
361
+ this.lastTool = { name: entry.name, at: nowM };
362
+ const event = {
363
+ ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
364
+ boot: this.bootId,
365
+ seq: this.seq++,
366
+ tool: entry.name,
367
+ ok,
368
+ ms: Math.round(ms),
369
+ chars: charsBucket(chars),
370
+ src: 'handler',
371
+ };
372
+ if (slug)
373
+ event.err = slug;
374
+ if (hinted !== undefined)
375
+ event.hint = hinted;
376
+ if (fan)
377
+ event.fan = fan;
378
+ // mid: set mid-dispatch by recordEscapeMethod, attached to the escape
379
+ // call's own event line (per-event field, never a synthetic event).
380
+ if (entry.name === 'google_api_call' && this.pendingMid) {
381
+ event.mid = this.pendingMid;
382
+ this.pendingMid = undefined;
383
+ }
384
+ this.events.push(JSON.stringify(event));
385
+ this.dirty = true;
386
+ }
387
+ /** Escape hatch: only the RESOLVED methodId (double-gated) or a miss. */
388
+ recordEscapeMethod(idOrNull) {
389
+ try {
390
+ this.rolloverIfNeeded();
391
+ if (idOrNull === null) {
392
+ this.deltas.escape.unknown_method += 1;
393
+ }
394
+ else if (idOrNull.length <= METHOD_ID_MAX && METHOD_ID_RE.test(idOrNull)) {
395
+ addInto(this.deltas.escape.methods, idOrNull);
396
+ this.pendingMid = idOrNull;
397
+ }
398
+ this.dirty = true;
399
+ }
400
+ catch { /* never throws outward */ }
401
+ }
402
+ /** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
403
+ recordSearchApi(resolvedApis) {
404
+ try {
405
+ this.rolloverIfNeeded();
406
+ if (resolvedApis === null)
407
+ this.deltas.escape.unknown_api += 1;
408
+ else
409
+ for (const k of resolvedApis)
410
+ addInto(this.deltas.escape.apis_searched, k);
411
+ this.dirty = true;
412
+ }
413
+ catch { /* never throws outward */ }
414
+ }
415
+ recordArgFix(tool, by = 1) {
416
+ try {
417
+ this.rolloverIfNeeded();
418
+ if (!TOOL_NAME_RE.test(tool))
419
+ return;
420
+ const t = (this.deltas.tools[tool] ??= { n: 0, err: {}, hint: 0, lat: {} });
421
+ t.argfix = (t.argfix ?? 0) + by;
422
+ this.dirty = true;
423
+ }
424
+ catch { /* never throws outward */ }
425
+ }
426
+ /** Protocol-level failures (no handler ran); wired by the transport tap. */
427
+ recordRpc(kind, tool) {
428
+ try {
429
+ this.rolloverIfNeeded();
430
+ if (kind === 'schema_validation') {
431
+ // The tap's name came off the wire; the shape gate keeps a weird
432
+ // -32602 from writing free text (registered names always pass).
433
+ if (tool && TOOL_NAME_RE.test(tool))
434
+ addInto(this.deltas.validation, tool);
435
+ }
436
+ else if (kind === 'tool_not_found') {
437
+ addInto(this.deltas.rpc, 'tool_not_found');
438
+ }
439
+ else {
440
+ addInto(this.deltas.rpc, RPC_CODES.has(kind) ? `rpc_error_${kind}` : 'rpc_error_other');
441
+ }
442
+ this.events.push(JSON.stringify({
443
+ ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
444
+ boot: this.bootId, seq: this.seq++,
445
+ tool: tool && TOOL_NAME_RE.test(tool) ? tool : 'other',
446
+ ok: false, ms: 0, chars: 'le64', src: 'rpc',
447
+ }));
448
+ this.dirty = true;
449
+ }
450
+ catch { /* never throws outward */ }
451
+ }
452
+ rolloverIfNeeded() {
453
+ const today = this.day();
454
+ if (this.deltas.day !== today) {
455
+ this.flush();
456
+ this.deltas = emptyDay(today);
457
+ this.pruneAggs();
458
+ this.dirty = false;
459
+ }
460
+ }
461
+ /** Merge deltas into the day file under lock; append buffered events. */
462
+ flush() {
463
+ if (!this.dirty || this.disabled)
464
+ return;
465
+ const deltas = this.deltas;
466
+ const events = this.events;
467
+ try {
468
+ const file = path.join(this.aggDir, `${deltas.day}.json`);
469
+ withFileLock(file, () => {
470
+ let current = emptyDay(deltas.day);
471
+ try {
472
+ const onDisk = JSON.parse(fs.readFileSync(file, 'utf-8'));
473
+ if (onDisk && onDisk.v === 1 && onDisk.day === deltas.day)
474
+ current = onDisk;
475
+ }
476
+ catch {
477
+ if (fs.existsSync(file))
478
+ this.log(`local usage metrics: unreadable day file ${file}; starting fresh\n`);
479
+ }
480
+ atomicWriteFileSync(file, JSON.stringify(applyCaps(mergeDay(current, deltas))));
481
+ });
482
+ if (events.length > 0) {
483
+ fs.appendFileSync(this.eventsFile, events.join('\n') + '\n', { mode: 0o600 });
484
+ this.rotateIfNeeded();
485
+ }
486
+ this.deltas = emptyDay(deltas.day);
487
+ this.events = [];
488
+ this.dirty = false;
489
+ this.failures = 0;
490
+ }
491
+ catch (e) {
492
+ this.failures += 1;
493
+ if (this.failures >= WRITE_FAILURE_LIMIT) {
494
+ this.disabled = true;
495
+ if (this.timer)
496
+ clearInterval(this.timer);
497
+ this.log(`local usage metrics: ${WRITE_FAILURE_LIMIT} consecutive write failures (${e.message}); disabled for this process\n`);
498
+ }
499
+ }
500
+ }
501
+ rotateIfNeeded() {
502
+ try {
503
+ if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
504
+ return;
505
+ this.pruneEventAge(this.eventsFile);
506
+ if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
507
+ return;
508
+ fs.renameSync(this.eventsFile, path.join(this.dir, 'events.1.jsonl'));
509
+ }
510
+ catch { /* rotation is best-effort */ }
511
+ }
512
+ /** Best-effort synchronous flush for shutdown paths. */
513
+ shutdown() {
514
+ if (this.timer)
515
+ clearInterval(this.timer);
516
+ this.flush();
517
+ }
518
+ /** For the doctor/diagnose status line. */
519
+ statusLine() {
520
+ try {
521
+ const files = [
522
+ ...fs.readdirSync(this.aggDir).map((f) => path.join(this.aggDir, f)),
523
+ this.eventsFile, path.join(this.dir, 'events.1.jsonl'),
524
+ ].filter((f) => fs.existsSync(f));
525
+ const bytes = files.reduce((s, f) => s + fs.statSync(f).size, 0);
526
+ return `${this.dir} (${files.length} files, ${Math.round(bytes / 1024)} KB)`;
527
+ }
528
+ catch {
529
+ return this.dir;
530
+ }
531
+ }
532
+ }
533
+ /** Read-only description of the metrics dir for doctor/diagnose status lines
534
+ * (never creates anything; safe to call with the feature off). */
535
+ export function describeMetricsDir(env = process.env) {
536
+ const dir = env.USAGE_METRICS_PATH ?? path.join(stateDir(env), METRICS_DIR_NAME);
537
+ let files = 0;
538
+ let bytes = 0;
539
+ try {
540
+ const candidates = [
541
+ ...fs.readdirSync(path.join(dir, 'agg')).map((f) => path.join(dir, 'agg', f)),
542
+ path.join(dir, 'events.jsonl'),
543
+ path.join(dir, 'events.1.jsonl'),
544
+ ];
545
+ for (const f of candidates) {
546
+ try {
547
+ bytes += fs.statSync(f).size;
548
+ files += 1;
549
+ }
550
+ catch { /* absent */ }
551
+ }
552
+ }
553
+ catch { /* dir absent: zeros */ }
554
+ return { dir, files, kb: Math.round(bytes / 1024) };
555
+ }
556
+ /**
557
+ * null when off: no directory, no file, no timer, nothing initializes; setting
558
+ * USAGE_METRICS_PATH alone never enables anything and never creates anything.
559
+ */
560
+ export function initUsageMetrics(state, opts) {
561
+ if (!state.enabled)
562
+ return null;
563
+ return new Metrics({
564
+ ...opts,
565
+ dir: opts.dir ?? (opts.env ?? process.env).USAGE_METRICS_PATH ?? undefined,
566
+ });
567
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.24",
3
+ "version": "6.0.0-alpha.26",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "license": "MIT",