mcp-google-multi 6.0.0-alpha.9 → 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 +6 -4
  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 +76 -0
  6. package/dist/arg-normalize.js +314 -0
  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 +13 -1
  23. package/dist/http-transport.js +10 -4
  24. package/dist/index.js +113 -10
  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 +54 -2
  34. package/dist/registry.js +180 -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 +12 -4
package/dist/doctor.js CHANGED
@@ -5,8 +5,11 @@ 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';
12
+ import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
10
13
  import { resolveHttpConfig, HttpConfigError } from './http-config.js';
11
14
  import { parseOwnerEmails } from './http-transport.js';
12
15
  const MIN_NODE_MAJOR = 22;
@@ -114,8 +117,24 @@ function sectionConfig(deps, set) {
114
117
  hint = (hint ? `${hint} ` : '') + `Move it: \`mv ${envFile} ${target}\`.`;
115
118
  }
116
119
  }
120
+ lines.push(usageMetricsStatusLine(deps.env));
117
121
  return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
118
122
  }
123
+ /** One line, state AND source, so the people being measured can see both
124
+ * here and in `diagnose` (metrics spec section 2). Read-only. */
125
+ export function usageMetricsStatusLine(env) {
126
+ const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
127
+ let configValue;
128
+ try {
129
+ configValue = loadConfigFile(undefined, 'throw')?.usageMetrics;
130
+ }
131
+ catch { /* invalid config is section 2's business, not this line's */ }
132
+ const state = resolveUsageMetrics(env, configValue, envSrc?.kind === 'file' ? envSrc.file : undefined);
133
+ if (!state.enabled)
134
+ return `local usage metrics: off ${sourceLabel(state.source)}`;
135
+ const d = describeMetricsDir(env);
136
+ return `local usage metrics: on ${sourceLabel(state.source)} -> ${d.dir} (${d.files} files, ${d.kb} KB)`;
137
+ }
119
138
  function sectionKeys(deps, aliases) {
120
139
  const provenance = deps.masterKeyProvenance();
121
140
  const tokensExist = deps.anyTokensExist(aliases);
@@ -338,9 +357,14 @@ export function exitCodeFor(report, strict) {
338
357
  return 0;
339
358
  }
340
359
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
341
- * engine; NOT alwaysLoad (the agent asks for it when diagnosing). */
360
+ * engine. Registered as a META tool, like `account_list`: it introspects this
361
+ * server rather than Google data, and as a normal tool it became a service of
362
+ * its own with no `{service}_discover` to reveal it (discover tools are built
363
+ * from already-registered services, and this runs after that), so it was
364
+ * advertised only once something else expanded the surface. The README sends
365
+ * people here when they are stuck, so it has to be findable. */
342
366
  export function registerDiagnoseTool(registry) {
343
- registry.registerTool('diagnose', {
367
+ registry.registerMeta('diagnose', {
344
368
  annotations: { readOnlyHint: true, openWorldHint: true },
345
369
  description: 'Health report for this server: runtime, config, keys, per-account token status, scope grants, and API enablement. Read-only; returns copy-pasteable fixes for anything wrong. Call this to diagnose auth/config failures.',
346
370
  inputSchema: {},
@@ -350,7 +374,11 @@ export function registerDiagnoseTool(registry) {
350
374
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
351
375
  }
352
376
  catch (e) {
353
- return { content: [{ type: 'text', text: JSON.stringify({ error: 'diagnose_failed', message: e?.message ?? String(e) }) }], isError: true };
377
+ return { content: [{ type: 'text', text: stringifyEnvelope({
378
+ error: 'diagnose_failed',
379
+ message: safeMessage(e),
380
+ retriable: false,
381
+ }) }], isError: true };
354
382
  }
355
383
  });
356
384
  }
@@ -9,3 +9,14 @@ export interface EnvLoadPaths {
9
9
  configDir?: string;
10
10
  }
11
11
  export declare function loadEnvFiles(paths?: EnvLoadPaths): EnvLoadResult;
12
+ /**
13
+ * Which layer supplied `key`: the real process env, or the first loaded .env
14
+ * file defining it (autoload never overwrites, so first wins). undefined when
15
+ * the key is unset. Powers self-announcing enablement sources (usage metrics).
16
+ */
17
+ export declare function envValueSource(key: string): {
18
+ kind: 'process';
19
+ } | {
20
+ kind: 'file';
21
+ file: string;
22
+ } | undefined;
package/dist/env-load.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { fileURLToPath } from 'node:url';
@@ -48,5 +48,27 @@ export function loadEnvFiles(paths = {}) {
48
48
  }
49
49
  for (const [k, v] of boot)
50
50
  process.env[k] = v;
51
+ lastLoad = { bootKeys: new Set(boot.keys()), loaded };
51
52
  return { loaded, searched: candidates };
52
53
  }
54
+ let lastLoad;
55
+ /**
56
+ * Which layer supplied `key`: the real process env, or the first loaded .env
57
+ * file defining it (autoload never overwrites, so first wins). undefined when
58
+ * the key is unset. Powers self-announcing enablement sources (usage metrics).
59
+ */
60
+ export function envValueSource(key) {
61
+ if (process.env[key] === undefined)
62
+ return undefined;
63
+ if (!lastLoad || lastLoad.bootKeys.has(key))
64
+ return { kind: 'process' };
65
+ const re = new RegExp(`^\\s*${key}\\s*=`, 'm');
66
+ for (const p of lastLoad.loaded) {
67
+ try {
68
+ if (re.test(readFileSync(p, 'utf-8')))
69
+ return { kind: 'file', file: p };
70
+ }
71
+ catch { /* file vanished since load: fall through */ }
72
+ }
73
+ return { kind: 'process' };
74
+ }
package/dist/executor.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { getClient } from './client.js';
2
+ import { sliceEncoded } from './trim.js';
2
3
  import { expandPath, isGoogleApiUrl } from './discovery-client.js';
3
4
  import { handleGoogleApiError } from './tools/_errors.js';
4
5
  import { scopeHintForMethod } from './scope-observability.js';
6
+ import { checkOutboundForMethod } from './outbound-allowlist.js';
5
7
  export const MAX_RESPONSE_CHARS = 100_000;
6
8
  export function jsonResult(payload, isError = false) {
7
9
  const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -29,6 +31,11 @@ export function resolveRequestBody(httpMethod, body) {
29
31
  return body ?? undefined;
30
32
  }
31
33
  export async function executeApiMethod(method, args, deps = {}) {
34
+ // Outbound allowlist: no-op when off; with it active, structured bodies are
35
+ // inspected for recipient fields and raw-compose methods are refused.
36
+ const outbound = checkOutboundForMethod(method.id, args.body, args.account);
37
+ if (outbound)
38
+ return outbound;
32
39
  if (args.queryParams?.alt === 'media') {
33
40
  return jsonResult({
34
41
  error: 'binary_unsupported',
@@ -82,10 +89,14 @@ export async function executeApiMethod(method, args, deps = {}) {
82
89
  });
83
90
  const text = JSON.stringify(res.data ?? null);
84
91
  if (text.length > MAX_RESPONSE_CHARS) {
92
+ // Budget the ENCODED head: jsonResult re-stringifies it, so every quote
93
+ // and backslash inside doubles. Slicing raw characters made the emitted
94
+ // result exceed the cap this tool advertises, by up to 15 percent on a
95
+ // quote-dense listing.
85
96
  return jsonResult({
86
97
  truncated: true,
87
98
  totalChars: text.length,
88
- head: text.slice(0, MAX_RESPONSE_CHARS),
99
+ head: sliceEncoded(text, MAX_RESPONSE_CHARS),
89
100
  hint: 'Narrow the request (fields mask, pageSize) to get complete JSON.',
90
101
  });
91
102
  }
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,6 +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, type StrictArgOptions, type ValidationEnvelopeOptions } from './arg-normalize.js';
4
5
  export type AuthOutcome = {
5
6
  ok: true;
6
7
  } | {
@@ -28,6 +29,17 @@ export interface HttpHostOptions {
28
29
  /** Deadline for a single /mcp dispatch; a hung handler past this releases the
29
30
  * shared lock instead of wedging the transport (default 120s). */
30
31
  dispatchTimeoutMs?: number;
32
+ /** tools/call argument-key normalization lookup (arg-normalize.ts); absent = off. */
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;
31
43
  }
32
44
  export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
33
45
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
@@ -1,10 +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';
7
+ import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
8
8
  // A hung handler that keeps the connection open would otherwise hold the global
9
9
  // serialize() lock forever. Generous by default so slow-but-valid calls (large
10
10
  // Drive exports, fan-out) still finish; the point is only to guarantee release.
@@ -160,7 +160,7 @@ export class HttpTransportHost {
160
160
  // the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
161
161
  // exact-Host match is stricter than the front guard and would 403 valid
162
162
  // Hosts (double enforcement, differing rules).
163
- const transport = new StreamableHTTPServerTransport({
163
+ const transport = new NodeStreamableHTTPServerTransport({
164
164
  sessionIdGenerator: undefined,
165
165
  enableJsonResponse: true,
166
166
  enableDnsRebindingProtection: false,
@@ -182,7 +182,13 @@ export class HttpTransportHost {
182
182
  timer = setTimeout(() => resolve('timeout'), deadlineMs);
183
183
  timer.unref?.();
184
184
  });
185
- await this.opts.server.connect(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);
186
192
  // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
187
193
  // race, an orphaned handler settling later must not surface as an unhandled
188
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,12 +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, 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';
23
28
  applyNetTuning();
24
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
30
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
26
- 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) {
27
46
  const policy = ctx.policy;
28
- const registry = new ToolRegistry(server, policy, mode);
47
+ const registry = new ToolRegistry(server, policy, mode, metrics);
29
48
  const toolsets = getToolsets();
30
49
  if (toolsets !== 'all') {
31
50
  const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
@@ -99,6 +118,22 @@ async function main() {
99
118
  process.exitCode = await runResetCli(process.argv);
100
119
  return;
101
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
+ }
102
137
  if (process.argv.includes('write-client-config')) {
103
138
  const { runWriteClientConfigCli } = await import('./client-config.js');
104
139
  process.exitCode = await runWriteClientConfigCli(process.argv);
@@ -182,14 +217,68 @@ async function main() {
182
217
  }
183
218
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
184
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
+ }
185
252
  // Build one McpServer + registry per transport at boot (P1 / BV gap #4:
186
253
  // never rebuilt per request); `both` runs the two concurrently.
187
254
  if (wantStdio) {
188
255
  const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
189
- 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);
190
258
  registry.installListHandler();
191
259
  registerSetupPrompt(server);
192
- await server.connect(new StdioServerTransport());
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);
193
282
  }
194
283
  if (wantHttp) {
195
284
  const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
@@ -206,7 +295,8 @@ async function main() {
206
295
  process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
207
296
  }
208
297
  const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
209
- 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);
210
300
  registry.installListHandler();
211
301
  registerSetupPrompt(httpServer);
212
302
  // B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
@@ -272,6 +362,11 @@ async function main() {
272
362
  // re-auth link into the AS's alias_reauth flow instead of a stdio CLI hint.
273
363
  const { setHttpReauthBase } = await import('./reauth-hint.js');
274
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
+ }
275
370
  const host = new HttpTransportHost({
276
371
  server: httpServer,
277
372
  config: httpCfg,
@@ -280,6 +375,14 @@ async function main() {
280
375
  authenticate: authServer.authenticate,
281
376
  routes: authServer.routes,
282
377
  log: (l) => process.stderr.write(`[http] ${l}\n`),
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),
283
386
  });
284
387
  await host.start();
285
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 {};