mcp-google-multi 6.0.0-beta.1 → 6.0.0-beta.2

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.
Files changed (103) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +5 -0
  3. package/dist/accounts.js +12 -1
  4. package/dist/api-probe.js +3 -0
  5. package/dist/arg-normalize.d.ts +61 -4
  6. package/dist/arg-normalize.js +228 -4
  7. package/dist/arg-strict.d.ts +54 -0
  8. package/dist/arg-strict.js +163 -0
  9. package/dist/auth.js +15 -72
  10. package/dist/client.js +15 -5
  11. package/dist/config-file.d.ts +1 -0
  12. package/dist/config-file.js +4 -0
  13. package/dist/discover.js +35 -14
  14. package/dist/discovery-client.d.ts +8 -1
  15. package/dist/discovery-client.js +96 -17
  16. package/dist/doctor.d.ts +9 -1
  17. package/dist/doctor.js +32 -4
  18. package/dist/env-load.d.ts +11 -0
  19. package/dist/env-load.js +23 -1
  20. package/dist/executor.js +12 -1
  21. package/dist/fanout.js +43 -8
  22. package/dist/http-transport.d.ts +11 -2
  23. package/dist/http-transport.js +10 -5
  24. package/dist/index.js +112 -12
  25. package/dist/metrics-cli.d.ts +21 -0
  26. package/dist/metrics-cli.js +206 -0
  27. package/dist/metrics-tap.d.ts +12 -0
  28. package/dist/metrics-tap.js +95 -0
  29. package/dist/oauth-consent.d.ts +25 -10
  30. package/dist/oauth-consent.js +85 -39
  31. package/dist/outbound-allowlist.d.ts +34 -0
  32. package/dist/outbound-allowlist.js +108 -0
  33. package/dist/registry.d.ts +49 -2
  34. package/dist/registry.js +147 -7
  35. package/dist/scope-catalog.js +16 -0
  36. package/dist/services.d.ts +8 -0
  37. package/dist/services.js +28 -1
  38. package/dist/setup-prompt.d.ts +1 -1
  39. package/dist/setup-prompt.js +1 -1
  40. package/dist/tools/_coerce.d.ts +5 -0
  41. package/dist/tools/_coerce.js +11 -0
  42. package/dist/tools/_errors.d.ts +31 -3
  43. package/dist/tools/_errors.js +307 -43
  44. package/dist/tools/_local-files.js +7 -2
  45. package/dist/tools/account-wizard.d.ts +24 -1
  46. package/dist/tools/account-wizard.js +152 -51
  47. package/dist/tools/admin.js +7 -6
  48. package/dist/tools/analytics.d.ts +18 -0
  49. package/dist/tools/analytics.js +284 -0
  50. package/dist/tools/calendar.d.ts +7 -0
  51. package/dist/tools/calendar.js +75 -15
  52. package/dist/tools/chat.js +7 -4
  53. package/dist/tools/contacts.js +57 -30
  54. package/dist/tools/docs.js +56 -54
  55. package/dist/tools/drive.js +132 -70
  56. package/dist/tools/forms.js +7 -7
  57. package/dist/tools/generated/_shared.d.ts +6 -0
  58. package/dist/tools/generated/_shared.js +11 -1
  59. package/dist/tools/generated/admin.js +345 -214
  60. package/dist/tools/generated/analytics.d.ts +2 -0
  61. package/dist/tools/generated/analytics.js +981 -0
  62. package/dist/tools/generated/appsmarket.js +4 -4
  63. package/dist/tools/generated/calendar.js +28 -28
  64. package/dist/tools/generated/chat.js +83 -56
  65. package/dist/tools/generated/classroom.js +258 -216
  66. package/dist/tools/generated/cloudidentity.js +76 -68
  67. package/dist/tools/generated/cloudsearch.js +40 -39
  68. package/dist/tools/generated/contacts.js +20 -11
  69. package/dist/tools/generated/drive.js +62 -53
  70. package/dist/tools/generated/drivelabels.js +60 -32
  71. package/dist/tools/generated/forms.js +7 -7
  72. package/dist/tools/generated/gmail.js +136 -109
  73. package/dist/tools/generated/groupsmigration.js +1 -1
  74. package/dist/tools/generated/groupssettings.js +3 -3
  75. package/dist/tools/generated/index.js +2 -0
  76. package/dist/tools/generated/keep.js +8 -7
  77. package/dist/tools/generated/licensing.js +39 -23
  78. package/dist/tools/generated/meet.js +14 -14
  79. package/dist/tools/generated/method-map.d.ts +2 -0
  80. package/dist/tools/generated/method-map.js +894 -0
  81. package/dist/tools/generated/postmaster.js +3 -3
  82. package/dist/tools/generated/reseller.js +34 -28
  83. package/dist/tools/generated/script.js +29 -19
  84. package/dist/tools/generated/searchconsole.js +5 -3
  85. package/dist/tools/generated/sheets.js +10 -9
  86. package/dist/tools/generated/tasks.js +10 -4
  87. package/dist/tools/generated/vault.js +65 -56
  88. package/dist/tools/generated/workspaceevents.js +17 -16
  89. package/dist/tools/gmail.d.ts +10 -1
  90. package/dist/tools/gmail.js +50 -18
  91. package/dist/tools/google-api.js +48 -19
  92. package/dist/tools/meet.js +5 -2
  93. package/dist/tools/searchconsole.js +10 -10
  94. package/dist/tools/sheets.js +36 -48
  95. package/dist/tools/slides.js +6 -6
  96. package/dist/tools/tasks.js +16 -16
  97. package/dist/trim.d.ts +21 -0
  98. package/dist/trim.js +47 -0
  99. package/dist/usage-metrics.d.ts +155 -0
  100. package/dist/usage-metrics.js +603 -0
  101. package/dist/write-control.d.ts +27 -1
  102. package/dist/write-control.js +90 -11
  103. package/package.json +8 -3
package/dist/fanout.js CHANGED
@@ -1,21 +1,33 @@
1
1
  import { z } from 'zod';
2
- import { ACCOUNTS, getAccountSet } from './accounts.js';
2
+ import { ACCOUNTS, getAccountSet, unknownAliasMessage } from './accounts.js';
3
3
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
4
4
  const FANOUT_CONCURRENCY = 5;
5
5
  export function fanoutAccountField(description) {
6
6
  const csvExample = ACCOUNTS.length > 1 ? `; or a CSV subset like "${ACCOUNTS.slice(0, 2).join(',')}"` : '';
7
+ // The union's OWN error only fires when every branch aborts at the type
8
+ // check (a non-string). For a string, zod surfaces the single non-aborted
9
+ // branch verbatim, which is the CSV regex, so a mistyped alias used to read
10
+ // as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
11
+ // named an alias. Hence the same message on BOTH.
12
+ const bad = () => unknownAliasMessage(ACCOUNTS, true);
7
13
  return z
8
14
  // '*' first so the tuple is statically non-empty even when ACCOUNTS is empty
9
15
  // (a fresh install): z.enum requires [string, ...string[]].
10
- .union([z.enum(['*', ...ACCOUNTS]), z.string().regex(CSV_RE)])
16
+ .union([z.enum(['*', ...ACCOUNTS]), z.string().regex(CSV_RE, { error: bad })], { error: bad })
11
17
  .optional()
12
18
  .describe(`${description}; "*" = all accounts${csvExample}; omit for the default account`);
13
19
  }
14
20
  export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
15
21
  if (value === '*')
16
22
  return { ok: true, fanout: true, aliases: [...accounts] };
17
- if (!value.includes(','))
18
- return { ok: true, fanout: false, aliases: [value] };
23
+ // A single alias used to skip validation entirely and reach getClient, whose
24
+ // throw landed on the mapper's floor as an unclassified upstream_error. Same
25
+ // check and same envelope wherever the alias was typed.
26
+ if (!value.includes(',')) {
27
+ return accounts.includes(value)
28
+ ? { ok: true, fanout: false, aliases: [value] }
29
+ : { ok: false, invalid: [value] };
30
+ }
19
31
  const seen = new Set();
20
32
  const aliases = [];
21
33
  const invalid = [];
@@ -30,7 +42,9 @@ export function parseAccountSelector(value, accounts = getAccountSet().aliases)
30
42
  }
31
43
  if (invalid.length > 0)
32
44
  return { ok: false, invalid };
33
- return { ok: true, fanout: aliases.length > 1, aliases };
45
+ // Keyed on the CSV FORM, not the deduped count: "a,a" asked for the merged
46
+ // multi-account envelope and used to get a bare single-account payload.
47
+ return { ok: true, fanout: true, aliases };
34
48
  }
35
49
  export function invalidAccountsResult(invalid, accounts = getAccountSet().aliases) {
36
50
  return {
@@ -40,8 +54,9 @@ export function invalidAccountsResult(invalid, accounts = getAccountSet().aliase
40
54
  text: JSON.stringify({
41
55
  error: 'validation_error',
42
56
  message: `Unknown account alias(es): ${invalid.join(', ')}.`,
43
- hint: `Valid aliases: ${accounts.join(', ')}; or "*" for all accounts.`,
57
+ hint: unknownAliasMessage(accounts, true),
44
58
  retriable: false,
59
+ account: invalid.join(','),
45
60
  }),
46
61
  },
47
62
  ],
@@ -82,7 +97,27 @@ export async function runFanout(handler, args, aliases) {
82
97
  };
83
98
  await Promise.all(Array.from({ length: Math.min(FANOUT_CONCURRENCY, aliases.length) }, () => worker()));
84
99
  const failed = results.filter((r) => !r.ok).length;
100
+ if (failed === results.length && results.length > 0) {
101
+ // Every account failed, so this IS an error result, and used to carry
102
+ // `isError` with no top-level error, hint or retriable: the agent saw a
103
+ // successful-looking object flagged as an error, and metrics bucketed it
104
+ // as `other`. Hoist a real envelope, keeping the per-account detail.
105
+ const slugs = [...new Set(results.map((r) => r.error?.error ?? 'internal'))];
106
+ const hints = [...new Set(results.map((r) => r.error?.hint).filter(Boolean))];
107
+ const retriable = results.every((r) => r.error?.retriable === true);
108
+ return {
109
+ content: [{ type: 'text', text: JSON.stringify({
110
+ error: 'fanout_failed',
111
+ message: `All ${results.length} accounts failed: ${slugs.join(', ')}. Per-account detail is in results.`,
112
+ hint: hints.length === 1 ? hints[0] : 'Each entry in results carries its own error and hint; they differ per account.',
113
+ retriable,
114
+ account: aliases.join(','),
115
+ results,
116
+ partial: true,
117
+ }) }],
118
+ isError: true,
119
+ };
120
+ }
85
121
  const body = { results, partial: failed > 0 };
86
- const out = { content: [{ type: 'text', text: JSON.stringify(body) }] };
87
- return failed === results.length ? { ...out, isError: true } : out;
122
+ return { content: [{ type: 'text', text: JSON.stringify(body) }] };
88
123
  }
@@ -1,7 +1,7 @@
1
+ import type { McpServer, Transport } from "@modelcontextprotocol/server";
1
2
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { HttpConfig } from './http-config.js';
4
- import { type ArgShape } from './arg-normalize.js';
4
+ import { type ArgShape, type StrictArgOptions, type ValidationEnvelopeOptions } from './arg-normalize.js';
5
5
  export type AuthOutcome = {
6
6
  ok: true;
7
7
  } | {
@@ -31,6 +31,15 @@ export interface HttpHostOptions {
31
31
  dispatchTimeoutMs?: number;
32
32
  /** tools/call argument-key normalization lookup (arg-normalize.ts); absent = off. */
33
33
  argShapeFor?: (tool: string) => ArgShape | undefined;
34
+ /** Usage-metrics transport tap (metrics-tap.ts); absent = off. */
35
+ metricsTap?: (t: Transport) => Transport;
36
+ /** Wired identically on both transports so a schema rejection reads the same
37
+ * over stdio and over HTTP. */
38
+ validationEnvelope?: ValidationEnvelopeOptions;
39
+ /** Usage-metrics argfix observer, forwarded into arg normalization. */
40
+ onArgRename?: (tool: string, renames: number) => void;
41
+ /** Unknown-argument screening (arg-strict.ts); absent = off. */
42
+ strictArgs?: StrictArgOptions;
34
43
  }
35
44
  export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
36
45
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
@@ -1,11 +1,10 @@
1
- // B12: the Streamable HTTP transport host (cc-transport-hosting T2/T3). Owns the
1
+ import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
2
2
  // node:http server, the route table, the front guard (Host / Origin / DNS-rebind),
3
3
  // and the stateless per-request /mcp dispatch. The OAuth AS endpoints and Bearer
4
4
  // verification are a seam filled by B13 (oauth-authorization-server); B12 ships a
5
5
  // loopback-owner authenticator so the local-HTTP model works before the AS lands.
6
6
  import { createServer } from 'node:http';
7
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
- import { withArgNormalization } from './arg-normalize.js';
7
+ import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
9
8
  // A hung handler that keeps the connection open would otherwise hold the global
10
9
  // serialize() lock forever. Generous by default so slow-but-valid calls (large
11
10
  // Drive exports, fan-out) still finish; the point is only to guarantee release.
@@ -161,7 +160,7 @@ export class HttpTransportHost {
161
160
  // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
162
161
  // exact-Host match is stricter than the front guard and would 403 valid
163
162
  // Hosts (double enforcement, differing rules).
164
- const transport = new StreamableHTTPServerTransport({
163
+ const transport = new NodeStreamableHTTPServerTransport({
165
164
  sessionIdGenerator: undefined,
166
165
  enableJsonResponse: true,
167
166
  enableDnsRebindingProtection: false,
@@ -183,7 +182,13 @@ export class HttpTransportHost {
183
182
  timer = setTimeout(() => resolve('timeout'), deadlineMs);
184
183
  timer.unref?.();
185
184
  });
186
- await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(transport, this.opts.argShapeFor, this.opts.log) : transport);
185
+ // Same composition as the stdio leg: the envelope rewrite is innermost,
186
+ // so the tap above it still classifies the original validation prose.
187
+ const enveloped = withValidationEnvelope(transport, this.opts.validationEnvelope ?? {});
188
+ const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
189
+ await this.opts.server.connect(this.opts.argShapeFor || this.opts.strictArgs
190
+ ? withArgNormalization(tapped, this.opts.argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, this.opts.strictArgs)
191
+ : tapped);
187
192
  // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
188
193
  // race, an orphaned handler settling later must not surface as an unhandled
189
194
  // rejection — but a genuine dispatch error still propagates (rethrown below).
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  // First import: triggers accounts.js module load (env files + registry) before
3
3
  // anything else. Named to also pull the server-only empty-registry guard (BR-4).
4
- import { assertServerAccountsConfigured } from './accounts.js';
4
+ import { assertServerAccountsConfigured, getAccountSet } from './accounts.js';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import path from 'node:path';
8
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
9
+ import { McpServer } from "@modelcontextprotocol/server";
10
10
  import { GENERATED_SERVICES } from './tools/generated/index.js';
11
- import { GENERATED_GATES, SERVICES } from './services.js';
12
- import { ToolRegistry } from './registry.js';
11
+ import { GENERATED_GATES, SERVICES, unknownToolMessage } from './services.js';
12
+ import { ToolRegistry, resolveDiscoveryMode } from './registry.js';
13
13
  import { registerDiscoverTools } from './discover.js';
14
14
  import { registerEscapeTools } from './tools/google-api.js';
15
15
  import { registerAccountTools } from './tools/accounts-tool.js';
@@ -20,13 +20,31 @@ import { isAllowed, describePolicy } from './write-control.js';
20
20
  import { buildIdentityContext } from './identity.js';
21
21
  import { registerSetupPrompt } from './setup-prompt.js';
22
22
  import { applyNetTuning } from './net-tuning.js';
23
- import { argNormalizationEnabled, withArgNormalization } from './arg-normalize.js';
23
+ import { argNormalizationEnabled, withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
24
+ import { unknownArgMode } from './arg-strict.js';
25
+ import { envValueSource } from './env-load.js';
26
+ import { loadConfigFile } from './config-file.js';
27
+ import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
24
28
  applyNetTuning();
25
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
30
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
27
- function buildRegistry(server, ctx, mode) {
31
+ /** Unknown-argument screening options, or undefined when the feature is off.
32
+ * Shared by both transports so a mistyped argument behaves identically. */
33
+ function strictArgOptions(registry, metrics) {
34
+ const mode = unknownArgMode();
35
+ if (mode === 'off')
36
+ return undefined;
37
+ return {
38
+ mode,
39
+ declaredFor: (tool) => registry.declaredKeys(tool),
40
+ siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
41
+ unknownTool: (name) => unknownToolMessage(registry, name),
42
+ onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
43
+ };
44
+ }
45
+ function buildRegistry(server, ctx, mode, metrics = null) {
28
46
  const policy = ctx.policy;
29
- const registry = new ToolRegistry(server, policy, mode);
47
+ const registry = new ToolRegistry(server, policy, mode, metrics);
30
48
  const toolsets = getToolsets();
31
49
  if (toolsets !== 'all') {
32
50
  const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
@@ -100,6 +118,22 @@ async function main() {
100
118
  process.exitCode = await runResetCli(process.argv);
101
119
  return;
102
120
  }
121
+ if (process.argv.includes('metrics')) {
122
+ const { runMetricsCli } = await import('./metrics-cli.js');
123
+ const { GENERATED_METHOD_TOOLS, CURATED_METHOD_IDS } = await import('./tools/generated/method-map.js');
124
+ const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
125
+ let configValue;
126
+ try {
127
+ configValue = loadConfigFile(undefined, 'throw')?.usageMetrics;
128
+ }
129
+ catch { /* an invalid config never blocks reading local metrics files */ }
130
+ const state = resolveUsageMetrics(process.env, configValue, envSrc?.kind === 'file' ? envSrc.file : undefined);
131
+ process.exitCode = runMetricsCli(process.argv, {
132
+ enabled: state.enabled,
133
+ promotion: { methodMap: GENERATED_METHOD_TOOLS, curatedIds: CURATED_METHOD_IDS },
134
+ });
135
+ return;
136
+ }
103
137
  if (process.argv.includes('write-client-config')) {
104
138
  const { runWriteClientConfigCli } = await import('./client-config.js');
105
139
  process.exitCode = await runWriteClientConfigCli(process.argv);
@@ -183,15 +217,68 @@ async function main() {
183
217
  }
184
218
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
185
219
  const wantHttp = transportIncludesHttp(httpCfg.transport);
220
+ // Local usage metrics: fail-closed resolve, self-announcing source, null
221
+ // when off (no wrapper, no dir, nothing initializes). One instance per
222
+ // transport so `boots` keys stay honest under `both`.
223
+ const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
224
+ const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
225
+ if (metricsState.warning)
226
+ process.stderr.write(metricsState.warning);
227
+ const metricsInstances = [];
228
+ const initMetricsFor = (transport, mode) => {
229
+ const m = initUsageMetrics(metricsState, { version: pkg.version, mode, transport });
230
+ if (m) {
231
+ metricsInstances.push(m);
232
+ process.stderr.write(`local usage metrics: on ${sourceLabel(metricsState.source)} -> ${m.statusLine()}\n`);
233
+ }
234
+ return m;
235
+ };
236
+ if (!metricsState.enabled && metricsState.source.kind !== 'default') {
237
+ process.stderr.write(`local usage metrics: off ${sourceLabel(metricsState.source)}\n`);
238
+ }
239
+ const flushMetrics = () => { for (const m of metricsInstances)
240
+ m.shutdown(); };
241
+ process.on('exit', flushMetrics);
242
+ if (metricsState.enabled) {
243
+ for (const sig of ['SIGTERM', 'SIGINT']) {
244
+ process.once(sig, () => {
245
+ flushMetrics();
246
+ // stdio has no other signal handler; preserve terminate-on-signal.
247
+ if (!wantHttp)
248
+ process.exit(sig === 'SIGINT' ? 130 : 143);
249
+ });
250
+ }
251
+ }
186
252
  // Build one McpServer + registry per transport at boot (P1 / BV gap #4:
187
253
  // never rebuilt per request); `both` runs the two concurrently.
188
254
  if (wantStdio) {
189
255
  const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
190
- const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
256
+ const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
257
+ const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
191
258
  registry.installListHandler();
192
259
  registerSetupPrompt(server);
193
- const stdioTransport = new StdioServerTransport();
194
- await server.connect(argNormalizationEnabled() ? withArgNormalization(stdioTransport, (n) => registry.argShape(n)) : stdioTransport);
260
+ // Outbound runs outermost-first, so the composition is deliberate: the
261
+ // envelope rewrite is INNERMOST (last to touch the frame), the tap sits
262
+ // above it (classifying the ORIGINAL validation prose), arg normalization
263
+ // outermost. The tap only counts outbound JSON-RPC error frames (no
264
+ // handler ran) plus id->tool names.
265
+ let transport = withValidationEnvelope(new StdioServerTransport(), {
266
+ isKnownTool: (n) => registry.hasTool(n),
267
+ defaultAccount: () => getAccountSet().defaultAccount,
268
+ });
269
+ if (stdioMetrics) {
270
+ const { tapUsageMetrics } = await import('./metrics-tap.js');
271
+ transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
272
+ }
273
+ const strictStdio = strictArgOptions(registry, stdioMetrics);
274
+ await server.connect(argNormalizationEnabled() || strictStdio
275
+ ? withArgNormalization(transport,
276
+ // Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
277
+ // shape unconditionally made stdio rename keys while
278
+ // GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
279
+ // and failed on HTTP once screening rejects.
280
+ argNormalizationEnabled() ? (n) => registry.argShape(n) : () => undefined, undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, strictStdio)
281
+ : transport);
195
282
  }
196
283
  if (wantHttp) {
197
284
  const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
@@ -208,7 +295,8 @@ async function main() {
208
295
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
209
296
  }
210
297
  const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
211
- const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated');
298
+ const httpMetrics = initMetricsFor('http', 'curated');
299
+ const registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
212
300
  registry.installListHandler();
213
301
  registerSetupPrompt(httpServer);
214
302
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
@@ -274,6 +362,11 @@ async function main() {
274
362
  // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
275
363
  const { setHttpReauthBase } = await import('./reauth-hint.js');
276
364
  setHttpReauthBase(httpCfg.publicUrl);
365
+ let httpTap;
366
+ if (httpMetrics) {
367
+ const { tapUsageMetrics } = await import('./metrics-tap.js');
368
+ httpTap = (t) => tapUsageMetrics(t, httpMetrics, (n) => registry.hasTool(n));
369
+ }
277
370
  const host = new HttpTransportHost({
278
371
  server: httpServer,
279
372
  config: httpCfg,
@@ -283,6 +376,13 @@ async function main() {
283
376
  routes: authServer.routes,
284
377
  log: (l) => process.stderr.write(`[http] ${l}\n`),
285
378
  argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
379
+ metricsTap: httpTap,
380
+ validationEnvelope: {
381
+ isKnownTool: (n) => registry.hasTool(n),
382
+ defaultAccount: () => getAccountSet().defaultAccount,
383
+ },
384
+ onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
385
+ strictArgs: strictArgOptions(registry, httpMetrics),
286
386
  });
287
387
  await host.start();
288
388
  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,12 @@
1
+ import type { Transport } from "@modelcontextprotocol/server";
2
+ import type { Metrics } from './usage-metrics.js';
3
+ import { isSchemaValidationText } from './arg-normalize.js';
4
+ export { isSchemaValidationText };
5
+ /**
6
+ * @param isKnownTool membership test against the REGISTERED tool set. The
7
+ * name on a `tools/call` frame is client-supplied, so a shape check alone
8
+ * would let a shape-valid but invented name reach disk and break the
9
+ * closed-vocabulary guarantee. A validation failure against a name the server
10
+ * never registered is a not-found, and is counted as one.
11
+ */
12
+ export declare function tapUsageMetrics(transport: Transport, metrics: Metrics, isKnownTool?: (name: string) => boolean): Transport;