mcp-google-multi 6.0.0-alpha.23 → 6.0.0-alpha.25

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.
@@ -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`);
@@ -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
+ }
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,11 +186,43 @@ 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 registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, initMetricsFor('stdio', resolveDiscoveryMode()));
191
226
  registry.installListHandler();
192
227
  registerSetupPrompt(server);
193
228
  const stdioTransport = new StdioServerTransport();
@@ -208,7 +243,7 @@ async function main() {
208
243
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
209
244
  }
210
245
  const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
211
- const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated');
246
+ const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', initMetricsFor('http', 'curated'));
212
247
  registry.installListHandler();
213
248
  registerSetupPrompt(httpServer);
214
249
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
@@ -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,7 +49,7 @@ 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
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) => {
@@ -1,4 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
3
3
  export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
4
+ /** Numeric args arrive string-encoded from some clients; over stdio the SDK
5
+ * validates the schema directly (the HTTP transport's arg-normalization layer
6
+ * never runs), so the coercion must live in the schema itself. */
7
+ export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
4
8
  export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
@@ -22,6 +22,16 @@ export function coerceArray(element) {
22
22
  export function coerceJson(schema) {
23
23
  return z.preprocess((val) => (typeof val === 'string' ? parseJsonLoose(val) : val), schema);
24
24
  }
25
+ /** Numeric args arrive string-encoded from some clients; over stdio the SDK
26
+ * validates the schema directly (the HTTP transport's arg-normalization layer
27
+ * never runs), so the coercion must live in the schema itself. */
28
+ export function coerceNumber(schema) {
29
+ return z.preprocess((val) => {
30
+ if (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val)))
31
+ return Number(val);
32
+ return val;
33
+ }, schema);
34
+ }
25
35
  export const coerceBoolean = z.preprocess((val) => {
26
36
  if (typeof val === 'boolean')
27
37
  return val;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { coerceArray, coerceBoolean } from './_coerce.js';
2
+ import { coerceArray, coerceBoolean, coerceNumber } from './_coerce.js';
3
3
  import { drive as driveClient } from '@googleapis/drive';
4
4
  import { accountAliasSchema, getAccountSet } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
@@ -129,7 +129,7 @@ export function registerDriveTools(server) {
129
129
  inputSchema: {
130
130
  account: accountEnum.describe('Google account alias'),
131
131
  query: z.string().describe('A plain keyword (full-text search) or Drive query syntax, e.g. "name contains \'MoU\'"'),
132
- maxResults: z.number().min(1).max(100).default(10).optional()
132
+ maxResults: coerceNumber(z.number().min(1).max(100)).optional()
133
133
  .describe('Max results to return (default: 10, max: 100)'),
134
134
  driveId: z.string().optional().describe('Optional shared drive ID'),
135
135
  },
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
2
+ import { coerceArray, coerceBoolean, coerceJson, coerceNumber } from './_coerce.js';
3
3
  import { gmail as gmailClient } from '@googleapis/gmail';
4
4
  import { accountAliasSchema } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
@@ -442,7 +442,7 @@ export function registerGmailTools(server) {
442
442
  inputSchema: {
443
443
  account: accountEnum.describe('Google account alias'),
444
444
  query: z.string().describe('Gmail search syntax, e.g. "from:monaam is:unread"'),
445
- maxResults: z.number().min(1).max(100).default(20).optional()
445
+ maxResults: coerceNumber(z.number().min(1).max(100)).optional()
446
446
  .describe('Max results to return (default: 20, max: 100)'),
447
447
  full: coerceBoolean.optional().describe('Return the full row shape instead of the compact default'),
448
448
  },
@@ -0,0 +1,98 @@
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): 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
+ /**
95
+ * null when off: no directory, no file, no timer, nothing initializes; setting
96
+ * USAGE_METRICS_PATH alone never enables anything and never creates anything.
97
+ */
98
+ export declare function initUsageMetrics(state: UsageMetricsState, opts: InitOptions): Metrics | null;
@@ -0,0 +1,538 @@
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
+ /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
32
+ * by a set-equality grep test). Anything outside buckets to `other`. */
33
+ export const KNOWN_ERROR_SLUGS = new Set([
34
+ 'E_CIMD_INVALID', 'E_MCP_TOKEN_INVALID', 'E_NO_DEFAULT_ACCOUNT', 'ambiguous',
35
+ 'api_not_enabled', 'auth_required', 'binary', 'binary_unsupported',
36
+ 'diagnose_failed', 'discovery_unavailable', 'dispatch_timeout', 'forbidden',
37
+ 'insufficient_scope', 'internal', 'invalid_client', 'invalid_client_metadata',
38
+ 'invalid_grant', 'invalid_params', 'invalid_query', 'invalid_request',
39
+ 'invalid_scope', 'network_error', 'not_found', 'rate_limited',
40
+ 'reauth_required', 'too_large', 'toolset_disabled', 'unknown_api',
41
+ 'unknown_method', 'unsupported_grant_type', 'unsupported_type',
42
+ 'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
43
+ ]);
44
+ const RPC_CODES = new Set([-32700, -32600, -32601, -32603]);
45
+ export function stateDir(env = process.env) {
46
+ return path.join(env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'mcp-google-multi');
47
+ }
48
+ export function latBucket(ms) {
49
+ for (const b of LAT_BOUNDS)
50
+ if (ms <= b)
51
+ return `le${b}`;
52
+ return 'gt30000';
53
+ }
54
+ export function charsBucket(n) {
55
+ for (const b of CHARS_BOUNDS)
56
+ if (n <= b)
57
+ return `le${b}`;
58
+ return 'gt65536';
59
+ }
60
+ export function fanBucket(width) {
61
+ if (width <= 1)
62
+ return undefined;
63
+ if (width === 2)
64
+ return 'w2';
65
+ if (width <= 4)
66
+ return 'w3to4';
67
+ return 'w5plus';
68
+ }
69
+ const ON = new Set(['on', '1', 'true', 'yes']);
70
+ const OFF = new Set(['off', '0', 'false', 'no']);
71
+ /**
72
+ * Fail-closed resolution (mirror of resolveDiscoveryMode's fail-open: here the
73
+ * safe state is off). Env wins over config; empty env value = unset, silent;
74
+ * any other value = one warning, off. `envFile` is the .env path that supplied
75
+ * the variable when it did not come from the real process env (attribution
76
+ * is computed by the caller; this module never reads env files).
77
+ */
78
+ export function resolveUsageMetrics(env, configValue, envFile) {
79
+ const raw = env.GOOGLE_USAGE_METRICS;
80
+ if (raw !== undefined && raw.trim() !== '') {
81
+ const v = raw.trim().toLowerCase();
82
+ const source = envFile ? { kind: 'env-file', file: envFile } : { kind: 'process-env' };
83
+ if (ON.has(v))
84
+ return { enabled: true, source };
85
+ if (OFF.has(v))
86
+ return { enabled: false, source };
87
+ return {
88
+ enabled: false,
89
+ source: { kind: 'default' },
90
+ warning: `GOOGLE_USAGE_METRICS="${raw}" is not a valid value (on|1|true|yes / off|0|false|no); local usage metrics stay OFF\n`,
91
+ };
92
+ }
93
+ if (configValue === true)
94
+ return { enabled: true, source: { kind: 'config' } };
95
+ return { enabled: false, source: { kind: 'default' } };
96
+ }
97
+ export function sourceLabel(source) {
98
+ switch (source.kind) {
99
+ case 'config': return '(config)';
100
+ case 'process-env': return '(process env)';
101
+ case 'env-file': return `(env file: ${source.file})`;
102
+ default: return '(default)';
103
+ }
104
+ }
105
+ function emptyDay(day) {
106
+ return {
107
+ v: 1, day, boots: {}, node: process.version, calls: 0, tools: {},
108
+ hints: {}, retries: {},
109
+ escape: { methods: {}, _overflow: 0, unknown_method: 0, apis_searched: {}, unknown_api: 0 },
110
+ validation: {}, rpc: {}, bigrams: {},
111
+ };
112
+ }
113
+ function addInto(target, key, by = 1) {
114
+ target[key] = (target[key] ?? 0) + by;
115
+ }
116
+ /** Deep-add b into a for the DayAgg shape (numbers add, maps union). */
117
+ function mergeDay(a, b) {
118
+ const num = (x, y) => (x ?? 0) + (y ?? 0);
119
+ const map = (x = {}, y = {}) => {
120
+ const out = { ...x };
121
+ for (const [k, v] of Object.entries(y))
122
+ out[k] = (out[k] ?? 0) + v;
123
+ return out;
124
+ };
125
+ const out = emptyDay(a.day);
126
+ out.node = b.node || a.node;
127
+ out.boots = map(a.boots, b.boots);
128
+ out.calls = num(a.calls, b.calls);
129
+ const toolNames = new Set([...Object.keys(a.tools), ...Object.keys(b.tools)]);
130
+ for (const name of toolNames) {
131
+ const x = a.tools[name];
132
+ const y = b.tools[name];
133
+ 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) };
134
+ if (x?.fanout || y?.fanout)
135
+ t.fanout = map(x?.fanout, y?.fanout);
136
+ const argfix = num(x?.argfix, y?.argfix);
137
+ if (argfix > 0)
138
+ t.argfix = argfix;
139
+ out.tools[name] = t;
140
+ }
141
+ const slugSet = new Set([...Object.keys(a.hints), ...Object.keys(b.hints)]);
142
+ for (const s of slugSet) {
143
+ out.hints[s] = {
144
+ hinted: num(a.hints[s]?.hinted, b.hints[s]?.hinted),
145
+ unhinted: num(a.hints[s]?.unhinted, b.hints[s]?.unhinted),
146
+ };
147
+ }
148
+ const retrySet = new Set([...Object.keys(a.retries), ...Object.keys(b.retries)]);
149
+ for (const s of retrySet) {
150
+ out.retries[s] = {
151
+ hintedOk: num(a.retries[s]?.hintedOk, b.retries[s]?.hintedOk),
152
+ hintedFail: num(a.retries[s]?.hintedFail, b.retries[s]?.hintedFail),
153
+ unhintedOk: num(a.retries[s]?.unhintedOk, b.retries[s]?.unhintedOk),
154
+ unhintedFail: num(a.retries[s]?.unhintedFail, b.retries[s]?.unhintedFail),
155
+ };
156
+ }
157
+ out.escape = {
158
+ methods: map(a.escape.methods, b.escape.methods),
159
+ _overflow: num(a.escape._overflow, b.escape._overflow),
160
+ unknown_method: num(a.escape.unknown_method, b.escape.unknown_method),
161
+ apis_searched: map(a.escape.apis_searched, b.escape.apis_searched),
162
+ unknown_api: num(a.escape.unknown_api, b.escape.unknown_api),
163
+ };
164
+ out.validation = map(a.validation, b.validation);
165
+ out.rpc = map(a.rpc, b.rpc);
166
+ out.bigrams = map(a.bigrams, b.bigrams);
167
+ return out;
168
+ }
169
+ /** Cap open maps at write time (the file may never exceed the caps). */
170
+ function applyCaps(day) {
171
+ const cap = (m, limit, overflowInto) => {
172
+ const entries = Object.entries(m);
173
+ if (entries.length <= limit)
174
+ return m;
175
+ entries.sort((x, y) => y[1] - x[1]);
176
+ const kept = Object.fromEntries(entries.slice(0, limit));
177
+ const dropped = entries.slice(limit).reduce((s, [, v]) => s + v, 0);
178
+ if (overflowInto)
179
+ overflowInto(dropped);
180
+ else
181
+ kept.other = (kept.other ?? 0) + dropped;
182
+ return kept;
183
+ };
184
+ for (const t of Object.values(day.tools))
185
+ t.err = cap(t.err, ERR_SLUG_CAP);
186
+ day.escape.methods = cap(day.escape.methods, ESCAPE_CAP, (n) => { day.escape._overflow += n; });
187
+ day.bigrams = cap(day.bigrams, BIGRAM_CAP, (n) => { day.bigrams._overflow = (day.bigrams._overflow ?? 0) + n; });
188
+ return day;
189
+ }
190
+ export class Metrics {
191
+ dir;
192
+ aggDir;
193
+ eventsFile;
194
+ now;
195
+ mono;
196
+ log;
197
+ bootKey;
198
+ bootId = randomBytes(3).toString('hex');
199
+ seq = 0;
200
+ deltas;
201
+ events = [];
202
+ lastError = new Map();
203
+ lastTool;
204
+ pendingMid;
205
+ timer;
206
+ dirty = false;
207
+ failures = 0;
208
+ disabled = false;
209
+ constructor(opts) {
210
+ this.dir = opts.dir ?? path.join(stateDir(opts.env), METRICS_DIR_NAME);
211
+ this.aggDir = path.join(this.dir, 'agg');
212
+ this.eventsFile = path.join(this.dir, 'events.jsonl');
213
+ this.now = opts.now ?? Date.now;
214
+ this.mono = opts.monotonic ?? (() => performance.now());
215
+ this.log = opts.log ?? ((l) => process.stderr.write(l));
216
+ this.bootKey = `${opts.version}/${opts.mode}/${opts.transport}`;
217
+ this.deltas = emptyDay(this.day());
218
+ this.deltas.boots[this.bootKey] = 1;
219
+ this.dirty = true;
220
+ this.initStorage();
221
+ this.timer = setInterval(() => this.flush(), FLUSH_MS);
222
+ this.timer.unref?.();
223
+ }
224
+ day() {
225
+ return new Date(this.now()).toISOString().slice(0, 10);
226
+ }
227
+ initStorage() {
228
+ try {
229
+ const existed = fs.existsSync(this.dir);
230
+ fs.mkdirSync(this.aggDir, { recursive: true, mode: 0o700 });
231
+ if (existed) {
232
+ const mode = fs.statSync(this.dir).mode & 0o777;
233
+ if ((mode & 0o077) !== 0) {
234
+ this.log(`local usage metrics: ${this.dir} has mode ${mode.toString(8)} (wider than 0700); not changing it, but the files record activity\n`);
235
+ }
236
+ }
237
+ this.pruneAggs();
238
+ this.pruneEventAge(this.eventsFile);
239
+ }
240
+ catch (e) {
241
+ this.log(`local usage metrics: init failed (${e.message}); disabled for this process\n`);
242
+ this.disabled = true;
243
+ }
244
+ }
245
+ pruneAggs() {
246
+ const cutoff = this.day2ms(this.day()) - RETENTION_DAYS * 86_400_000;
247
+ for (const f of fs.readdirSync(this.aggDir)) {
248
+ const m = f.match(/^(\d{4}-\d{2}-\d{2})\.json$/);
249
+ if (m && this.day2ms(m[1]) < cutoff) {
250
+ try {
251
+ fs.rmSync(path.join(this.aggDir, f));
252
+ }
253
+ catch { /* prune is best-effort */ }
254
+ }
255
+ }
256
+ }
257
+ day2ms(day) {
258
+ return Date.parse(`${day}T00:00:00Z`);
259
+ }
260
+ pruneEventAge(file) {
261
+ if (!fs.existsSync(file))
262
+ return;
263
+ const cutoff = this.now() - RETENTION_DAYS * 86_400_000;
264
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
265
+ const kept = lines.filter((l) => {
266
+ if (!l.trim())
267
+ return false;
268
+ try {
269
+ const ts = JSON.parse(l).ts;
270
+ return typeof ts === 'string' && Date.parse(ts) >= cutoff;
271
+ }
272
+ catch {
273
+ return false; // torn or corrupt line: drop
274
+ }
275
+ });
276
+ if (kept.length !== lines.filter((l) => l.trim()).length) {
277
+ atomicWriteFileSync(file, kept.join('\n') + (kept.length ? '\n' : ''));
278
+ }
279
+ }
280
+ /** Outermost dispatch wrapper; a throw inside recording never affects the call. */
281
+ wrap(entry, handler, widthOf) {
282
+ return async (...args) => {
283
+ const started = this.mono();
284
+ const result = await handler(...args);
285
+ try {
286
+ this.record(entry, args[0], result, this.mono() - started, widthOf);
287
+ }
288
+ catch { /* metrics may never fail a tool call */ }
289
+ return result;
290
+ };
291
+ }
292
+ record(entry, firstArg, result, ms, widthOf) {
293
+ if (this.disabled)
294
+ return;
295
+ this.rolloverIfNeeded();
296
+ const r = result;
297
+ const ok = !r?.isError;
298
+ let slug;
299
+ let hinted;
300
+ if (!ok) {
301
+ try {
302
+ const first = r?.content?.[0]?.text;
303
+ if (typeof first === 'string' && first.length < 65_536) {
304
+ const env = JSON.parse(first);
305
+ if (typeof env.error === 'string')
306
+ slug = KNOWN_ERROR_SLUGS.has(env.error) ? env.error : 'other';
307
+ hinted = typeof env.hint === 'string' && env.hint.length > 0;
308
+ }
309
+ }
310
+ catch {
311
+ slug = 'other';
312
+ }
313
+ slug ??= 'other';
314
+ }
315
+ const chars = (r?.content ?? []).reduce((s, c) => s + (typeof c.text === 'string' ? c.text.length : 0), 0);
316
+ let width = 1;
317
+ if (widthOf) {
318
+ try {
319
+ width = widthOf(firstArg);
320
+ }
321
+ catch {
322
+ width = 1;
323
+ }
324
+ }
325
+ const fan = fanBucket(width);
326
+ const t = (this.deltas.tools[entry.name] ??= { n: 0, err: {}, hint: 0, lat: {} });
327
+ t.n += 1;
328
+ this.deltas.calls += 1;
329
+ addInto(t.lat, latBucket(ms));
330
+ if (slug) {
331
+ addInto(t.err, slug);
332
+ if (hinted)
333
+ t.hint += 1;
334
+ const h = (this.deltas.hints[slug] ??= { hinted: 0, unhinted: 0 });
335
+ if (hinted)
336
+ h.hinted += 1;
337
+ else
338
+ h.unhinted += 1;
339
+ }
340
+ if (fan) {
341
+ const f = (t.fanout ??= { calls: 0 });
342
+ f.calls += 1;
343
+ addInto(f, fan);
344
+ }
345
+ // retry self-correction chain (spec section 1): in-memory only.
346
+ const nowM = this.mono();
347
+ const prev = this.lastError.get(entry.name);
348
+ if (prev && nowM - prev.at <= RETRY_WINDOW_MS) {
349
+ const rr = (this.deltas.retries[prev.slug] ??= { hintedOk: 0, hintedFail: 0, unhintedOk: 0, unhintedFail: 0 });
350
+ const key = `${prev.hinted ? 'hinted' : 'unhinted'}${ok ? 'Ok' : 'Fail'}`;
351
+ rr[key] += 1;
352
+ this.lastError.delete(entry.name);
353
+ }
354
+ if (!ok && slug)
355
+ this.lastError.set(entry.name, { slug, hinted: hinted === true, at: nowM });
356
+ // bigrams: ordered pairs within the process, 5-minute gap breaks the chain.
357
+ if (this.lastTool && nowM - this.lastTool.at <= BIGRAM_GAP_MS) {
358
+ addInto(this.deltas.bigrams, `${this.lastTool.name}>${entry.name}`);
359
+ }
360
+ this.lastTool = { name: entry.name, at: nowM };
361
+ const event = {
362
+ ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
363
+ boot: this.bootId,
364
+ seq: this.seq++,
365
+ tool: entry.name,
366
+ ok,
367
+ ms: Math.round(ms),
368
+ chars: charsBucket(chars),
369
+ src: 'handler',
370
+ };
371
+ if (slug)
372
+ event.err = slug;
373
+ if (hinted !== undefined)
374
+ event.hint = hinted;
375
+ if (fan)
376
+ event.fan = fan;
377
+ // mid: set mid-dispatch by recordEscapeMethod, attached to the escape
378
+ // call's own event line (per-event field, never a synthetic event).
379
+ if (entry.name === 'google_api_call' && this.pendingMid) {
380
+ event.mid = this.pendingMid;
381
+ this.pendingMid = undefined;
382
+ }
383
+ this.events.push(JSON.stringify(event));
384
+ this.dirty = true;
385
+ }
386
+ /** Escape hatch: only the RESOLVED methodId (double-gated) or a miss. */
387
+ recordEscapeMethod(idOrNull) {
388
+ try {
389
+ this.rolloverIfNeeded();
390
+ if (idOrNull === null) {
391
+ this.deltas.escape.unknown_method += 1;
392
+ }
393
+ else if (idOrNull.length <= METHOD_ID_MAX && METHOD_ID_RE.test(idOrNull)) {
394
+ addInto(this.deltas.escape.methods, idOrNull);
395
+ this.pendingMid = idOrNull;
396
+ }
397
+ this.dirty = true;
398
+ }
399
+ catch { /* never throws outward */ }
400
+ }
401
+ /** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
402
+ recordSearchApi(resolvedApis) {
403
+ try {
404
+ this.rolloverIfNeeded();
405
+ if (resolvedApis === null)
406
+ this.deltas.escape.unknown_api += 1;
407
+ else
408
+ for (const k of resolvedApis)
409
+ addInto(this.deltas.escape.apis_searched, k);
410
+ this.dirty = true;
411
+ }
412
+ catch { /* never throws outward */ }
413
+ }
414
+ recordArgFix(tool) {
415
+ try {
416
+ this.rolloverIfNeeded();
417
+ const t = (this.deltas.tools[tool] ??= { n: 0, err: {}, hint: 0, lat: {} });
418
+ t.argfix = (t.argfix ?? 0) + 1;
419
+ this.dirty = true;
420
+ }
421
+ catch { /* never throws outward */ }
422
+ }
423
+ /** Protocol-level failures (no handler ran); wired by the transport tap. */
424
+ recordRpc(kind, tool) {
425
+ try {
426
+ this.rolloverIfNeeded();
427
+ if (kind === 'schema_validation') {
428
+ if (tool)
429
+ addInto(this.deltas.validation, tool);
430
+ }
431
+ else if (kind === 'tool_not_found') {
432
+ addInto(this.deltas.rpc, 'tool_not_found');
433
+ }
434
+ else {
435
+ addInto(this.deltas.rpc, RPC_CODES.has(kind) ? `rpc_error_${kind}` : 'rpc_error_other');
436
+ }
437
+ this.events.push(JSON.stringify({
438
+ ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
439
+ boot: this.bootId, seq: this.seq++,
440
+ tool: tool ?? 'other', ok: false, ms: 0, chars: 'le64', src: 'rpc',
441
+ }));
442
+ this.dirty = true;
443
+ }
444
+ catch { /* never throws outward */ }
445
+ }
446
+ rolloverIfNeeded() {
447
+ const today = this.day();
448
+ if (this.deltas.day !== today) {
449
+ this.flush();
450
+ this.deltas = emptyDay(today);
451
+ this.pruneAggs();
452
+ this.dirty = false;
453
+ }
454
+ }
455
+ /** Merge deltas into the day file under lock; append buffered events. */
456
+ flush() {
457
+ if (!this.dirty || this.disabled)
458
+ return;
459
+ const deltas = this.deltas;
460
+ const events = this.events;
461
+ try {
462
+ const file = path.join(this.aggDir, `${deltas.day}.json`);
463
+ withFileLock(file, () => {
464
+ let current = emptyDay(deltas.day);
465
+ try {
466
+ const onDisk = JSON.parse(fs.readFileSync(file, 'utf-8'));
467
+ if (onDisk && onDisk.v === 1 && onDisk.day === deltas.day)
468
+ current = onDisk;
469
+ }
470
+ catch {
471
+ if (fs.existsSync(file))
472
+ this.log(`local usage metrics: unreadable day file ${file}; starting fresh\n`);
473
+ }
474
+ atomicWriteFileSync(file, JSON.stringify(applyCaps(mergeDay(current, deltas))));
475
+ });
476
+ if (events.length > 0) {
477
+ fs.appendFileSync(this.eventsFile, events.join('\n') + '\n', { mode: 0o600 });
478
+ this.rotateIfNeeded();
479
+ }
480
+ this.deltas = emptyDay(deltas.day);
481
+ this.events = [];
482
+ this.dirty = false;
483
+ this.failures = 0;
484
+ }
485
+ catch (e) {
486
+ this.failures += 1;
487
+ if (this.failures >= WRITE_FAILURE_LIMIT) {
488
+ this.disabled = true;
489
+ if (this.timer)
490
+ clearInterval(this.timer);
491
+ this.log(`local usage metrics: ${WRITE_FAILURE_LIMIT} consecutive write failures (${e.message}); disabled for this process\n`);
492
+ }
493
+ }
494
+ }
495
+ rotateIfNeeded() {
496
+ try {
497
+ if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
498
+ return;
499
+ this.pruneEventAge(this.eventsFile);
500
+ if (fs.statSync(this.eventsFile).size < EVENTS_ROTATE_BYTES)
501
+ return;
502
+ fs.renameSync(this.eventsFile, path.join(this.dir, 'events.1.jsonl'));
503
+ }
504
+ catch { /* rotation is best-effort */ }
505
+ }
506
+ /** Best-effort synchronous flush for shutdown paths. */
507
+ shutdown() {
508
+ if (this.timer)
509
+ clearInterval(this.timer);
510
+ this.flush();
511
+ }
512
+ /** For the doctor/diagnose status line. */
513
+ statusLine() {
514
+ try {
515
+ const files = [
516
+ ...fs.readdirSync(this.aggDir).map((f) => path.join(this.aggDir, f)),
517
+ this.eventsFile, path.join(this.dir, 'events.1.jsonl'),
518
+ ].filter((f) => fs.existsSync(f));
519
+ const bytes = files.reduce((s, f) => s + fs.statSync(f).size, 0);
520
+ return `${this.dir} (${files.length} files, ${Math.round(bytes / 1024)} KB)`;
521
+ }
522
+ catch {
523
+ return this.dir;
524
+ }
525
+ }
526
+ }
527
+ /**
528
+ * null when off: no directory, no file, no timer, nothing initializes; setting
529
+ * USAGE_METRICS_PATH alone never enables anything and never creates anything.
530
+ */
531
+ export function initUsageMetrics(state, opts) {
532
+ if (!state.enabled)
533
+ return null;
534
+ return new Metrics({
535
+ ...opts,
536
+ dir: opts.dir ?? (opts.env ?? process.env).USAGE_METRICS_PATH ?? undefined,
537
+ });
538
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.23",
3
+ "version": "6.0.0-alpha.25",
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",
@@ -58,6 +58,7 @@
58
58
  "gen:discovery": "tsx scripts/fetch-discovery.ts",
59
59
  "gen:tools": "tsx scripts/gen-tools.ts",
60
60
  "gen:coverage": "tsx scripts/gen-coverage.ts",
61
+ "eval:contract": "npx --yes promptfoo@0.123.1 eval -c eval/contract/promptfooconfig.yaml --no-progress-bar",
61
62
  "measure:lazy": "node scripts/measure-tools.mjs",
62
63
  "pack:mcpb": "mcpb pack . mcp-google-multi.mcpb",
63
64
  "bundle": "esbuild dist/index.js --bundle --platform=node --format=esm --outfile=bundle/index.js --external:@napi-rs/keyring --banner:js=\"import{createRequire as __dockerCR}from'node:module';var require=__dockerCR(import.meta.url);\" --log-level=warning"