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 +4 -0
- package/dist/arg-normalize.d.ts +2 -2
- package/dist/arg-normalize.js +7 -3
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +19 -1
- package/dist/http-transport.d.ts +5 -0
- package/dist/http-transport.js +2 -1
- package/dist/index.js +37 -4
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +3 -0
- package/dist/metrics-tap.js +75 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +4 -0
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/google-api.js +6 -0
- package/dist/usage-metrics.d.ts +49 -1
- package/dist/usage-metrics.js +34 -5
- package/package.json +1 -1
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/arg-normalize.d.ts
CHANGED
|
@@ -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;
|
package/dist/arg-normalize.js
CHANGED
|
@@ -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);
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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
|
package/dist/http-transport.js
CHANGED
|
@@ -183,7 +183,8 @@ export class HttpTransportHost {
|
|
|
183
183
|
timer = setTimeout(() => resolve('timeout'), deadlineMs);
|
|
184
184
|
timer.unref?.();
|
|
185
185
|
});
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
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,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
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -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. */
|