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.
- package/README.md +5 -3
- package/dist/accounts.d.ts +5 -0
- package/dist/accounts.js +12 -1
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +61 -4
- package/dist/arg-normalize.js +228 -4
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +96 -17
- package/dist/doctor.d.ts +9 -1
- package/dist/doctor.js +32 -4
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.js +43 -8
- package/dist/http-transport.d.ts +11 -2
- package/dist/http-transport.js +10 -5
- package/dist/index.js +112 -12
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/registry.d.ts +49 -2
- package/dist/registry.js +147 -7
- package/dist/scope-catalog.js +16 -0
- package/dist/services.d.ts +8 -0
- package/dist/services.js +28 -1
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +31 -3
- package/dist/tools/_errors.js +307 -43
- package/dist/tools/_local-files.js +7 -2
- package/dist/tools/account-wizard.d.ts +24 -1
- package/dist/tools/account-wizard.js +152 -51
- package/dist/tools/admin.js +7 -6
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +284 -0
- package/dist/tools/calendar.d.ts +7 -0
- package/dist/tools/calendar.js +75 -15
- package/dist/tools/chat.js +7 -4
- package/dist/tools/contacts.js +57 -30
- package/dist/tools/docs.js +56 -54
- package/dist/tools/drive.js +132 -70
- package/dist/tools/forms.js +7 -7
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +345 -214
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.js +4 -4
- package/dist/tools/generated/calendar.js +28 -28
- package/dist/tools/generated/chat.js +83 -56
- package/dist/tools/generated/classroom.js +258 -216
- package/dist/tools/generated/cloudidentity.js +76 -68
- package/dist/tools/generated/cloudsearch.js +40 -39
- package/dist/tools/generated/contacts.js +20 -11
- package/dist/tools/generated/drive.js +62 -53
- package/dist/tools/generated/drivelabels.js +60 -32
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.js +136 -109
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +8 -7
- package/dist/tools/generated/licensing.js +39 -23
- package/dist/tools/generated/meet.js +14 -14
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.js +3 -3
- package/dist/tools/generated/reseller.js +34 -28
- package/dist/tools/generated/script.js +29 -19
- package/dist/tools/generated/searchconsole.js +5 -3
- package/dist/tools/generated/sheets.js +10 -9
- package/dist/tools/generated/tasks.js +10 -4
- package/dist/tools/generated/vault.js +65 -56
- package/dist/tools/generated/workspaceevents.js +17 -16
- package/dist/tools/gmail.d.ts +10 -1
- package/dist/tools/gmail.js +50 -18
- package/dist/tools/google-api.js +48 -19
- package/dist/tools/meet.js +5 -2
- package/dist/tools/searchconsole.js +10 -10
- package/dist/tools/sheets.js +36 -48
- package/dist/tools/slides.js +6 -6
- package/dist/tools/tasks.js +16 -16
- package/dist/trim.d.ts +21 -0
- package/dist/trim.js +47 -0
- package/dist/usage-metrics.d.ts +155 -0
- package/dist/usage-metrics.js +603 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- 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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
87
|
-
return failed === results.length ? { ...out, isError: true } : out;
|
|
122
|
+
return { content: [{ type: 'text', text: JSON.stringify(body) }] };
|
|
88
123
|
}
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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
|
package/dist/http-transport.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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;
|