mcp-google-multi 6.0.0-alpha.26 → 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/)
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);
@@ -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
+ }
@@ -20,6 +20,19 @@ export function tapUsageMetrics(transport, metrics) {
20
20
  metrics.recordRpc(m.error.code);
21
21
  }
22
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
+ }
23
36
  }
24
37
  }
25
38
  catch { /* the tap may never break the wire */ }
@@ -0,0 +1,2 @@
1
+ export declare const GENERATED_METHOD_TOOLS: Record<string, string>;
2
+ export declare const CURATED_METHOD_IDS: readonly string[];