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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,6 +55,10 @@ New to all this? It's written for someone who just installed Claude Code and has
55
55
 
56
56
  **Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
57
57
 
58
+ ## Local usage metrics (off by default)
59
+
60
+ The server can keep anonymous, **local-only** usage aggregates for its operator: tool names, error classes, latency buckets. Never arguments, payloads, message content, accounts, or identities of any kind, and **zero network egress ever** — the data cannot leave your machine unless you copy files yourself. It is off until you set `GOOGLE_USAGE_METRICS=on`; when on, the boot log and `doctor` say so and name the source. Read your own data with `mcp-google-multi metrics report`. Details, file format, and the honest threat model: [docs/usage-metrics.md](./docs/usage-metrics.md).
61
+
58
62
  ## Maintainer & credits
59
63
 
60
64
  Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.xyz) · [@bakissation](https://github.com/bakissation). [Read the case study →](https://berkati.xyz/case-studies/mcp-google-multi/)
@@ -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
@@ -103,6 +103,22 @@ async function main() {
103
103
  process.exitCode = await runResetCli(process.argv);
104
104
  return;
105
105
  }
106
+ if (process.argv.includes('metrics')) {
107
+ const { runMetricsCli } = await import('./metrics-cli.js');
108
+ const { GENERATED_METHOD_TOOLS, CURATED_METHOD_IDS } = await import('./tools/generated/method-map.js');
109
+ const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
110
+ let configValue;
111
+ try {
112
+ configValue = loadConfigFile(undefined, 'throw')?.usageMetrics;
113
+ }
114
+ catch { /* an invalid config never blocks reading local metrics files */ }
115
+ const state = resolveUsageMetrics(process.env, configValue, envSrc?.kind === 'file' ? envSrc.file : undefined);
116
+ process.exitCode = runMetricsCli(process.argv, {
117
+ enabled: state.enabled,
118
+ promotion: { methodMap: GENERATED_METHOD_TOOLS, curatedIds: CURATED_METHOD_IDS },
119
+ });
120
+ return;
121
+ }
106
122
  if (process.argv.includes('write-client-config')) {
107
123
  const { runWriteClientConfigCli } = await import('./client-config.js');
108
124
  process.exitCode = await runWriteClientConfigCli(process.argv);
@@ -222,11 +238,20 @@ async function main() {
222
238
  // never rebuilt per request); `both` runs the two concurrently.
223
239
  if (wantStdio) {
224
240
  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()));
241
+ const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
242
+ const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
226
243
  registry.installListHandler();
227
244
  registerSetupPrompt(server);
228
- const stdioTransport = new StdioServerTransport();
229
- await server.connect(argNormalizationEnabled() ? withArgNormalization(stdioTransport, (n) => registry.argShape(n)) : stdioTransport);
245
+ // Tap sits under arg normalization at the same wrap site; it only counts
246
+ // outbound JSON-RPC error frames (no handler ran) plus id->tool names.
247
+ let transport = new StdioServerTransport();
248
+ if (stdioMetrics) {
249
+ const { tapUsageMetrics } = await import('./metrics-tap.js');
250
+ transport = tapUsageMetrics(transport, stdioMetrics);
251
+ }
252
+ await server.connect(argNormalizationEnabled()
253
+ ? withArgNormalization(transport, (n) => registry.argShape(n), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined)
254
+ : transport);
230
255
  }
231
256
  if (wantHttp) {
232
257
  const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
@@ -243,7 +268,8 @@ async function main() {
243
268
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
244
269
  }
245
270
  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'));
271
+ const httpMetrics = initMetricsFor('http', 'curated');
272
+ const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
247
273
  registry.installListHandler();
248
274
  registerSetupPrompt(httpServer);
249
275
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
@@ -309,6 +335,11 @@ async function main() {
309
335
  // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
310
336
  const { setHttpReauthBase } = await import('./reauth-hint.js');
311
337
  setHttpReauthBase(httpCfg.publicUrl);
338
+ let httpTap;
339
+ if (httpMetrics) {
340
+ const { tapUsageMetrics } = await import('./metrics-tap.js');
341
+ httpTap = (t) => tapUsageMetrics(t, httpMetrics);
342
+ }
312
343
  const host = new HttpTransportHost({
313
344
  server: httpServer,
314
345
  config: httpCfg,
@@ -318,6 +349,8 @@ async function main() {
318
349
  routes: authServer.routes,
319
350
  log: (l) => process.stderr.write(`[http] ${l}\n`),
320
351
  argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
352
+ metricsTap: httpTap,
353
+ onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
321
354
  });
322
355
  await host.start();
323
356
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
@@ -0,0 +1,21 @@
1
+ import { type DayAgg } from './usage-metrics.js';
2
+ export interface PromotionData {
3
+ methodMap: Record<string, string>;
4
+ curatedIds: readonly string[];
5
+ }
6
+ export interface MetricsCliDeps {
7
+ enabled: boolean;
8
+ promotion: PromotionData;
9
+ env?: NodeJS.ProcessEnv;
10
+ out?: (line: string) => void;
11
+ }
12
+ interface PromotionRow {
13
+ methodId: string;
14
+ tool?: string;
15
+ calls: number;
16
+ kind: 'curation-candidate' | 'visibility-failure' | 'generation-gap';
17
+ }
18
+ /** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
19
+ export declare function classifyPromotion(merged: DayAgg, data: PromotionData): PromotionRow[];
20
+ export declare function runMetricsCli(argv: string[], deps: MetricsCliDeps): number;
21
+ export {};
@@ -0,0 +1,206 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { describeMetricsDir, mergeDay } from './usage-metrics.js';
4
+ function loadDayDocs(dir, sinceDays) {
5
+ const aggDir = path.join(dir, 'agg');
6
+ let files;
7
+ try {
8
+ files = fs.readdirSync(aggDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
9
+ }
10
+ catch {
11
+ return [];
12
+ }
13
+ const cutoff = sinceDays !== undefined ? Date.now() - sinceDays * 86_400_000 : undefined;
14
+ const docs = [];
15
+ for (const f of files) {
16
+ if (cutoff !== undefined && Date.parse(`${f.slice(0, 10)}T00:00:00Z`) < cutoff)
17
+ continue;
18
+ try {
19
+ const doc = JSON.parse(fs.readFileSync(path.join(aggDir, f), 'utf-8'));
20
+ if (doc && doc.v === 1)
21
+ docs.push(doc);
22
+ }
23
+ catch { /* unreadable day file: skip, the report is best-effort */ }
24
+ }
25
+ return docs;
26
+ }
27
+ function sum(docs) {
28
+ return docs.reduce((a, b) => mergeDay(a, b));
29
+ }
30
+ function parseSince(argv) {
31
+ const i = argv.indexOf('--since');
32
+ if (i === -1 || !argv[i + 1])
33
+ return undefined;
34
+ const m = argv[i + 1].match(/^(\d+)d$/);
35
+ return m ? Number(m[1]) : undefined;
36
+ }
37
+ const pct = (num, den) => (den === 0 ? '-' : `${Math.round((num / den) * 100)}%`);
38
+ const top = (m, n) => Object.entries(m).filter(([k]) => k !== '_overflow').sort((a, b) => b[1] - a[1]).slice(0, n);
39
+ function renderReport(merged, days, out) {
40
+ out(`local usage metrics: ${days} day file(s), ${merged.calls} calls, node ${merged.node}`);
41
+ out(`boots: ${Object.entries(merged.boots).map(([k, v]) => `${k}=${v}`).join(' ') || '(none)'}`);
42
+ out('');
43
+ out('tool calls err err% hint top slug');
44
+ for (const [name, t] of Object.entries(merged.tools).sort((a, b) => b[1].n - a[1].n).slice(0, 30)) {
45
+ const errs = Object.values(t.err).reduce((s, v) => s + v, 0);
46
+ const slug = top(t.err, 1)[0]?.[0] ?? '';
47
+ out(`${name.padEnd(34)}${String(t.n).padStart(5)}${String(errs).padStart(6)}${pct(errs, t.n).padStart(6)}${String(t.hint).padStart(6)} ${slug}`);
48
+ }
49
+ const hintRows = Object.entries(merged.hints);
50
+ if (hintRows.length > 0) {
51
+ out('');
52
+ out('hint coverage per error class (hinted/total)');
53
+ for (const [slug, h] of hintRows.sort((a, b) => b[1].hinted + b[1].unhinted - a[1].hinted - a[1].unhinted)) {
54
+ out(` ${slug.padEnd(24)}${h.hinted}/${h.hinted + h.unhinted} (${pct(h.hinted, h.hinted + h.unhinted)})`);
55
+ }
56
+ }
57
+ const retryRows = Object.entries(merged.retries);
58
+ if (retryRows.length > 0) {
59
+ out('');
60
+ out('retry self-correction per error class (ok/total after hinted vs unhinted errors)');
61
+ for (const [slug, r] of retryRows) {
62
+ out(` ${slug.padEnd(24)}hinted ${r.hintedOk}/${r.hintedOk + r.hintedFail} (${pct(r.hintedOk, r.hintedOk + r.hintedFail)}) unhinted ${r.unhintedOk}/${r.unhintedOk + r.unhintedFail} (${pct(r.unhintedOk, r.unhintedOk + r.unhintedFail)})`);
63
+ }
64
+ }
65
+ const esc = merged.escape;
66
+ if (Object.keys(esc.methods).length > 0 || esc.unknown_method > 0 || esc.unknown_api > 0) {
67
+ out('');
68
+ out(`escape hatch: unknown_method=${esc.unknown_method} unknown_api=${esc.unknown_api} overflow=${esc._overflow}`);
69
+ for (const [id, n] of top(esc.methods, 20))
70
+ out(` ${id.padEnd(50)}${n}`);
71
+ const apis = top(esc.apis_searched, 10).map(([k, v]) => `${k}=${v}`).join(' ');
72
+ if (apis)
73
+ out(` searched: ${apis}`);
74
+ }
75
+ if (Object.keys(merged.validation).length > 0) {
76
+ out('');
77
+ out('schema validation rejections (never reached a handler)');
78
+ for (const [tool, n] of top(merged.validation, 15))
79
+ out(` ${tool.padEnd(34)}${n}`);
80
+ }
81
+ const bigrams = top(merged.bigrams, 10);
82
+ if (bigrams.length > 0) {
83
+ out('');
84
+ out('top tool bigrams');
85
+ for (const [pair, n] of bigrams)
86
+ out(` ${pair.padEnd(50)}${n}`);
87
+ }
88
+ }
89
+ const tail = (id) => id.split('.').slice(1).join('.');
90
+ /** The promotion-queue view: ranked evidence, a human decides (spec section 5). */
91
+ export function classifyPromotion(merged, data) {
92
+ const curated = new Set();
93
+ for (const id of data.curatedIds) {
94
+ curated.add(id);
95
+ curated.add(tail(id));
96
+ }
97
+ const byId = new Map();
98
+ const byTail = new Map();
99
+ const toolToId = new Map();
100
+ for (const [id, tool] of Object.entries(data.methodMap)) {
101
+ byId.set(id, tool);
102
+ byTail.set(tail(id), tool);
103
+ toolToId.set(tool, id);
104
+ }
105
+ const rows = [];
106
+ // (a) generated tools with real traffic = curation candidates.
107
+ for (const [tool, t] of Object.entries(merged.tools)) {
108
+ const id = toolToId.get(tool);
109
+ if (id && !curated.has(id) && !curated.has(tail(id))) {
110
+ rows.push({ methodId: id, tool, calls: t.n, kind: 'curation-candidate' });
111
+ }
112
+ }
113
+ // (b)/(c) escape methodIds; the tail join covers legacy doc prefixes.
114
+ for (const [id, calls] of Object.entries(merged.escape.methods)) {
115
+ if (curated.has(id) || curated.has(tail(id)))
116
+ continue; // already curated: drops out
117
+ const twin = byId.get(id) ?? byTail.get(tail(id));
118
+ rows.push(twin
119
+ ? { methodId: id, tool: twin, calls, kind: 'visibility-failure' }
120
+ : { methodId: id, calls, kind: 'generation-gap' });
121
+ }
122
+ return rows.sort((a, b) => b.calls - a.calls);
123
+ }
124
+ function renderPromotion(rows, out) {
125
+ const section = (kind, title, note) => {
126
+ const list = rows.filter((r) => r.kind === kind);
127
+ if (list.length === 0)
128
+ return;
129
+ out(`### ${title}`);
130
+ out(note);
131
+ out('');
132
+ out('| methodId | tool | calls |');
133
+ out('|---|---|---|');
134
+ for (const r of list)
135
+ out(`| ${r.methodId} | ${r.tool ?? '(none)'} | ${r.calls} |`);
136
+ out('');
137
+ };
138
+ if (rows.length === 0) {
139
+ out('No promotion evidence yet: no generated-tool traffic and no escape-hatch methodIds recorded.');
140
+ return;
141
+ }
142
+ section('curation-candidate', 'Curation candidates', 'Generated tools with real traffic; a curated wrapper may be earned.');
143
+ section('visibility-failure', 'Visibility failures', 'The escape hatch was used where a generated tool exists; discovery/naming problem, not a coverage gap.');
144
+ section('generation-gap', 'Generation gaps', 'Escape methodIds with no tool at all.');
145
+ }
146
+ export function runMetricsCli(argv, deps) {
147
+ const out = deps.out ?? ((l) => process.stdout.write(`${l}\n`));
148
+ const env = deps.env ?? process.env;
149
+ const json = argv.includes('--json');
150
+ const sub = argv.includes('merge') ? 'merge' : 'report';
151
+ let merged;
152
+ let dayCount = 0;
153
+ if (sub === 'merge') {
154
+ const files = argv.slice(argv.indexOf('merge') + 1).filter((a) => !a.startsWith('--'));
155
+ if (files.length === 0) {
156
+ out('usage: mcp-google-multi metrics merge <file...> [--json]');
157
+ return 2;
158
+ }
159
+ const docs = [];
160
+ for (const f of files) {
161
+ let parsed;
162
+ try {
163
+ parsed = JSON.parse(fs.readFileSync(f, 'utf-8'));
164
+ }
165
+ catch (e) {
166
+ out(`cannot read ${f}: ${e.message}`);
167
+ return 2;
168
+ }
169
+ // A report wrapper carries {v, days, agg}; a raw day doc carries tools.
170
+ const doc = parsed.agg?.v === 1 ? parsed.agg : parsed.v === 1 && parsed.tools ? parsed : undefined;
171
+ if (!doc || doc.v !== 1) {
172
+ out(`${f} is neither a day aggregate nor a metrics report --json output`);
173
+ return 2;
174
+ }
175
+ docs.push(doc);
176
+ dayCount += 1;
177
+ }
178
+ merged = sum(docs);
179
+ }
180
+ else {
181
+ const dir = describeMetricsDir(env).dir;
182
+ const docs = loadDayDocs(dir, parseSince(argv));
183
+ if (docs.length === 0) {
184
+ out(deps.enabled
185
+ ? `local usage metrics are on but no day files exist yet under ${dir}`
186
+ : 'local usage metrics are off (default); enable with GOOGLE_USAGE_METRICS=on');
187
+ return 0;
188
+ }
189
+ merged = sum(docs);
190
+ dayCount = docs.length;
191
+ }
192
+ if (argv.includes('--promotion')) {
193
+ const rows = classifyPromotion(merged, deps.promotion);
194
+ if (json)
195
+ out(JSON.stringify({ v: 1, days: dayCount, promotion: rows }));
196
+ else
197
+ renderPromotion(rows, out);
198
+ return 0;
199
+ }
200
+ if (json) {
201
+ out(JSON.stringify({ v: 1, days: dayCount, agg: merged }));
202
+ return 0;
203
+ }
204
+ renderReport(merged, dayCount, out);
205
+ return 0;
206
+ }
@@ -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,75 @@
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
+ else if (m.result?.isError === true) {
24
+ // SDK 1.x synthesizes input-validation failures as isError RESULTS
25
+ // ("MCP error -32602: ..."), skipping the handler entirely, so the
26
+ // registry wrapper never sees them either. Handler envelopes are
27
+ // JSON text and never carry this prefix, so nothing double counts.
28
+ const first = m.result.content?.[0]?.text;
29
+ if (typeof first === 'string' && first.startsWith('MCP error -32602:')) {
30
+ if (/tool \S+ not found|unknown tool/i.test(first))
31
+ metrics.recordRpc('tool_not_found');
32
+ else
33
+ metrics.recordRpc('schema_validation', tool);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ catch { /* the tap may never break the wire */ }
39
+ return transport.send(message, options);
40
+ },
41
+ close: () => transport.close(),
42
+ };
43
+ Object.defineProperty(wrapper, 'onmessage', {
44
+ get: () => transport.onmessage,
45
+ set: (handler) => {
46
+ transport.onmessage = handler
47
+ ? (message, extra) => {
48
+ try {
49
+ const m = message;
50
+ if (m.id !== undefined && m.method === 'tools/call' && typeof m.params?.name === 'string') {
51
+ if (pending.size >= PENDING_CAP)
52
+ pending.clear();
53
+ pending.set(m.id, m.params.name);
54
+ }
55
+ }
56
+ catch { /* never break the wire */ }
57
+ handler(message, extra);
58
+ }
59
+ : undefined;
60
+ },
61
+ });
62
+ for (const prop of ['onclose', 'onerror']) {
63
+ Object.defineProperty(wrapper, prop, {
64
+ get: () => transport[prop],
65
+ set: (v) => {
66
+ transport[prop] = v;
67
+ },
68
+ });
69
+ }
70
+ Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
71
+ if (transport.setProtocolVersion) {
72
+ wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
73
+ }
74
+ return wrapper;
75
+ }
@@ -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. */
@@ -0,0 +1,2 @@
1
+ export declare const GENERATED_METHOD_TOOLS: Record<string, string>;
2
+ export declare const CURATED_METHOD_IDS: readonly string[];