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.
- package/README.md +6 -4
- 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 +76 -0
- package/dist/arg-normalize.js +314 -0
- 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 +13 -1
- package/dist/http-transport.js +10 -4
- package/dist/index.js +113 -10
- 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 +54 -2
- package/dist/registry.js +180 -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 +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
|
|
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.
|
|
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:
|
|
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
|
}
|
package/dist/env-load.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,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
|
package/dist/http-transport.js
CHANGED
|
@@ -1,10 +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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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,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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {};
|