mcp-google-multi 6.0.0-alpha.25 → 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
  });
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);
@@ -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
@@ -222,11 +222,20 @@ async function main() {
222
222
  // never rebuilt per request); `both` runs the two concurrently.
223
223
  if (wantStdio) {
224
224
  const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
225
- const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, initMetricsFor('stdio', resolveDiscoveryMode()));
225
+ const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
226
+ const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
226
227
  registry.installListHandler();
227
228
  registerSetupPrompt(server);
228
- const stdioTransport = new StdioServerTransport();
229
- 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);
230
239
  }
231
240
  if (wantHttp) {
232
241
  const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
@@ -243,7 +252,8 @@ async function main() {
243
252
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
244
253
  }
245
254
  const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
246
- const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', initMetricsFor('http', 'curated'));
255
+ const httpMetrics = initMetricsFor('http', 'curated');
256
+ const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
247
257
  registry.installListHandler();
248
258
  registerSetupPrompt(httpServer);
249
259
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
@@ -309,6 +319,11 @@ async function main() {
309
319
  // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
310
320
  const { setHttpReauthBase } = await import('./reauth-hint.js');
311
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
+ }
312
327
  const host = new HttpTransportHost({
313
328
  server: httpServer,
314
329
  config: httpCfg,
@@ -318,6 +333,8 @@ async function main() {
318
333
  routes: authServer.routes,
319
334
  log: (l) => process.stderr.write(`[http] ${l}\n`),
320
335
  argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
336
+ metricsTap: httpTap,
337
+ onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
321
338
  });
322
339
  await host.start();
323
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
+ }
@@ -56,6 +56,8 @@ export declare class ToolRegistry {
56
56
  * normalization; the kind drives value coercion on renamed keys). */
57
57
  argShape(name: string): ArgShape | undefined;
58
58
  catalog(service: string, query?: string): CatalogOperation[];
59
+ /** The metrics recorder, for hook sites (escape hatch); null when off. */
60
+ get usageMetrics(): Metrics | null;
59
61
  /** Op-name vocabulary for a service, split by provenance so the capped
60
62
  * discover descriptions can list curated ops and only summarize the
61
63
  * generated long tail. */
package/dist/registry.js CHANGED
@@ -252,6 +252,10 @@ export class ToolRegistry {
252
252
  cud: t.cud,
253
253
  }));
254
254
  }
255
+ /** The metrics recorder, for hook sites (escape hatch); null when off. */
256
+ get usageMetrics() {
257
+ return this.metrics;
258
+ }
255
259
  /** Op-name vocabulary for a service, split by provenance so the capped
256
260
  * discover descriptions can list curated ops and only summarize the
257
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;
@@ -79,7 +79,7 @@ export declare class Metrics {
79
79
  recordEscapeMethod(idOrNull: string | null): void;
80
80
  /** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
81
81
  recordSearchApi(resolvedApis: string[] | null): void;
82
- recordArgFix(tool: string): void;
82
+ recordArgFix(tool: string, by?: number): void;
83
83
  /** Protocol-level failures (no handler ran); wired by the transport tap. */
84
84
  recordRpc(kind: 'schema_validation' | 'tool_not_found' | number, tool?: string): void;
85
85
  private rolloverIfNeeded;
@@ -91,6 +91,13 @@ export declare class Metrics {
91
91
  /** For the doctor/diagnose status line. */
92
92
  statusLine(): string;
93
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
+ };
94
101
  /**
95
102
  * null when off: no directory, no file, no timer, nothing initializes; setting
96
103
  * USAGE_METRICS_PATH alone never enables anything and never creates anything.
@@ -28,6 +28,7 @@ const CHARS_BOUNDS = [64, 256, 1024, 4096, 16384, 65536];
28
28
  // arbitrary strings into recorded values (spec section 1).
29
29
  const METHOD_ID_RE = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)+$/;
30
30
  const METHOD_ID_MAX = 128;
31
+ const TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
31
32
  /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
32
33
  * by a set-equality grep test). Anything outside buckets to `other`. */
33
34
  export const KNOWN_ERROR_SLUGS = new Set([
@@ -411,11 +412,13 @@ export class Metrics {
411
412
  }
412
413
  catch { /* never throws outward */ }
413
414
  }
414
- recordArgFix(tool) {
415
+ recordArgFix(tool, by = 1) {
415
416
  try {
416
417
  this.rolloverIfNeeded();
418
+ if (!TOOL_NAME_RE.test(tool))
419
+ return;
417
420
  const t = (this.deltas.tools[tool] ??= { n: 0, err: {}, hint: 0, lat: {} });
418
- t.argfix = (t.argfix ?? 0) + 1;
421
+ t.argfix = (t.argfix ?? 0) + by;
419
422
  this.dirty = true;
420
423
  }
421
424
  catch { /* never throws outward */ }
@@ -425,7 +428,9 @@ export class Metrics {
425
428
  try {
426
429
  this.rolloverIfNeeded();
427
430
  if (kind === 'schema_validation') {
428
- if (tool)
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))
429
434
  addInto(this.deltas.validation, tool);
430
435
  }
431
436
  else if (kind === 'tool_not_found') {
@@ -437,7 +442,8 @@ export class Metrics {
437
442
  this.events.push(JSON.stringify({
438
443
  ts: new Date(this.now()).toISOString().slice(0, 16) + ':00Z',
439
444
  boot: this.bootId, seq: this.seq++,
440
- tool: tool ?? 'other', ok: false, ms: 0, chars: 'le64', src: 'rpc',
445
+ tool: tool && TOOL_NAME_RE.test(tool) ? tool : 'other',
446
+ ok: false, ms: 0, chars: 'le64', src: 'rpc',
441
447
  }));
442
448
  this.dirty = true;
443
449
  }
@@ -524,6 +530,29 @@ export class Metrics {
524
530
  }
525
531
  }
526
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
+ }
527
556
  /**
528
557
  * null when off: no directory, no file, no timer, nothing initializes; setting
529
558
  * USAGE_METRICS_PATH alone never enables anything and never creates anything.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.25",
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",