mcp-google-multi 6.0.0-alpha.72 → 6.0.0-alpha.74
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/dist/accounts.js +12 -25
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.js +4 -9
- package/dist/boot-gates.js +2 -4
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.js +6 -12
- package/dist/client.js +0 -0
- package/dist/compose.js +4 -11
- package/dist/config-file.js +7 -12
- package/dist/discover.js +6 -12
- package/dist/discovery-client.js +14 -33
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +7 -12
- package/dist/env-load.js +2 -5
- package/dist/executor.js +2 -4
- package/dist/fanout.js +8 -17
- package/dist/fs-atomic.d.ts +0 -3
- package/dist/fs-atomic.js +6 -10
- package/dist/hint-provider.d.ts +4 -1
- package/dist/hint-provider.js +4 -10
- package/dist/http-config.js +2 -5
- package/dist/http-transport.js +35 -88
- package/dist/identity.js +4 -7
- package/dist/index.js +7 -18
- package/dist/master-key.js +7 -18
- package/dist/mcp-token.d.ts +37 -13
- package/dist/mcp-token.js +118 -41
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +2 -4
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +6 -1
- package/dist/oauth-as.js +41 -74
- package/dist/oauth-consent.js +4 -10
- package/dist/outbound-allowlist.js +4 -9
- package/dist/reauth-hint.js +2 -5
- package/dist/registry-transfer.js +4 -12
- package/dist/registry.d.ts +2 -8
- package/dist/registry.js +89 -76
- package/dist/scope-catalog.js +4 -7
- package/dist/scope-observability.d.ts +1 -2
- package/dist/scope-observability.js +8 -14
- package/dist/services.d.ts +3 -0
- package/dist/services.js +5 -10
- package/dist/ssrf-guard.js +11 -21
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_errors.js +21 -38
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.js +27 -48
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +17 -6
- package/dist/tools/admin.js +2 -3
- package/dist/tools/analytics.js +4 -5
- package/dist/tools/calendar.js +4 -7
- package/dist/tools/chat.js +2 -3
- package/dist/tools/contacts.js +5 -6
- package/dist/tools/docs.js +4 -9
- package/dist/tools/drive.js +18 -27
- package/dist/tools/forms.js +2 -3
- package/dist/tools/generated/gmail.js +0 -14
- package/dist/tools/generated/method-map.js +1 -1
- package/dist/tools/gmail-mime.js +6 -11
- package/dist/tools/gmail.d.ts +1 -1
- package/dist/tools/gmail.js +131 -48
- package/dist/tools/google-api.js +4 -6
- package/dist/tools/meet.js +2 -3
- package/dist/tools/searchconsole.js +2 -3
- package/dist/tools/sheets.js +2 -3
- package/dist/tools/slides.js +2 -3
- package/dist/tools/tasks.js +2 -3
- package/dist/trim.js +2 -3
- package/dist/usage-metrics.js +5 -12
- package/dist/write-control.d.ts +0 -3
- package/dist/write-control.js +17 -5
- package/package.json +1 -1
package/dist/accounts.js
CHANGED
|
@@ -75,8 +75,7 @@ function noAccountsMessage() {
|
|
|
75
75
|
*/
|
|
76
76
|
let warnedLegacy = false;
|
|
77
77
|
export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit',
|
|
78
|
-
// emptyOk: zero accounts is a valid set, not an error, even with 'throw'
|
|
79
|
-
// (a context that has linked nothing yet). An invalid file still fails.
|
|
78
|
+
// emptyOk: zero accounts is a valid set, not an error, even with 'throw'.
|
|
80
79
|
opts = {}) {
|
|
81
80
|
const dir = opts.tokenDir ?? tokenDir;
|
|
82
81
|
const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
|
|
@@ -86,8 +85,7 @@ opts = {}) {
|
|
|
86
85
|
throw new Error(`${slug}: ${message}`);
|
|
87
86
|
failStartup(slug, message);
|
|
88
87
|
};
|
|
89
|
-
// Validate
|
|
90
|
-
// selects — otherwise a typo only surfaces at dispatch time inside
|
|
88
|
+
// Validate at BOOT, or a typo only surfaces at dispatch time inside
|
|
91
89
|
// account_list instead of the promised startup error.
|
|
92
90
|
const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
|
|
93
91
|
if (legacyNames.length > 0) {
|
|
@@ -120,19 +118,13 @@ opts = {}) {
|
|
|
120
118
|
...def,
|
|
121
119
|
};
|
|
122
120
|
}
|
|
123
|
-
// Stat BEFORE read
|
|
124
|
-
// stamp conservative (flags stale again next dispatch) instead of pinning
|
|
125
|
-
// stale content behind a fresh stamp.
|
|
121
|
+
// Stat BEFORE read, so a race lands on the conservative (stale) side.
|
|
126
122
|
const preStamp = fileStamp(filePath, CONFIG_VERSION);
|
|
127
123
|
const config = loadConfigFile(filePath, onInvalid);
|
|
128
124
|
const entries = Object.entries(config?.accounts ?? {});
|
|
129
125
|
if (entries.length === 0) {
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
// must run on a fresh install. Only the stdio/http SERVER refuses to boot
|
|
133
|
-
// empty, enforced by assertServerAccountsConfigured() in index.ts.
|
|
134
|
-
// The dispatch-path reload ('throw') still throws so a mid-session emptied
|
|
135
|
-
// config.json keeps the last-good registry instead of dropping tools.
|
|
126
|
+
// Not fatal here: bootstrap/diagnostic CLIs must run on a fresh install;
|
|
127
|
+
// only the server refuses to boot empty (index.ts).
|
|
136
128
|
if (onInvalid === 'throw' && !opts.emptyOk) {
|
|
137
129
|
throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
|
|
138
130
|
}
|
|
@@ -144,9 +136,8 @@ opts = {}) {
|
|
|
144
136
|
stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
|
|
145
137
|
};
|
|
146
138
|
}
|
|
147
|
-
// Unknown bundle names fail loudly (a mis-scoped token is worse than a
|
|
148
|
-
//
|
|
149
|
-
// user input and must never collide with Object.prototype members.
|
|
139
|
+
// Unknown bundle names fail loudly (a mis-scoped token is worse than a clear
|
|
140
|
+
// error). Null prototype: profile names are user input, must never collide with Object.prototype.
|
|
150
141
|
const scopeProfiles = Object.create(null);
|
|
151
142
|
scopeProfiles.base = { bundles: [] };
|
|
152
143
|
for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
|
|
@@ -187,9 +178,8 @@ opts = {}) {
|
|
|
187
178
|
...def,
|
|
188
179
|
};
|
|
189
180
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
* deliberately NOT E_CONFIG_INVALID: the config is schema-valid). */
|
|
181
|
+
// env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset; an unknown alias
|
|
182
|
+
// refuses to start as E_DEFAULT_ACCOUNT_UNKNOWN, not E_CONFIG_INVALID (the config is schema-valid).
|
|
193
183
|
function resolveDefaultAccount(env, fromConfig, aliases, fail) {
|
|
194
184
|
const fromEnv = env.GOOGLE_DEFAULT_ACCOUNT?.trim();
|
|
195
185
|
const value = fromEnv || fromConfig || undefined;
|
|
@@ -208,17 +198,14 @@ export function fileStamp(filePath, version) {
|
|
|
208
198
|
return `${version}:0`;
|
|
209
199
|
}
|
|
210
200
|
}
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// a write failure must never block boot (warn on stderr and continue).
|
|
201
|
+
// env is set and no config.json exists yet: materialize the file so the
|
|
202
|
+
// wizard has something to edit; a write failure must never block boot.
|
|
214
203
|
function materializeFirstRun(aliases, configs, filePath) {
|
|
215
204
|
if (fs.existsSync(filePath))
|
|
216
205
|
return;
|
|
217
206
|
try {
|
|
218
207
|
let wrote = false;
|
|
219
|
-
//
|
|
220
|
-
// wizard/migrate writer is never clobbered (the loaded `current` is
|
|
221
|
-
// re-read under the lock; only a still-absent file gets the env content).
|
|
208
|
+
// lock + re-check + atomic write, so a concurrent wizard/migrate writer is never clobbered.
|
|
222
209
|
mutateConfigFile((current) => {
|
|
223
210
|
if (Object.keys(current.accounts ?? {}).length > 0)
|
|
224
211
|
return current;
|
package/dist/arg-normalize.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
|
|
2
2
|
import { logSafe, sliceClean } from './trim.js';
|
|
3
|
-
// The SDK synthesizes input-validation failures as isError RESULTS
|
|
4
|
-
//
|
|
5
|
-
// client gets free text. The text is prose in both SDK eras (v1 prefixed it
|
|
6
|
-
// with "MCP error -32602: ", v2 dropped that prefix), while handler envelopes
|
|
7
|
-
// are always JSON starting with "{".
|
|
3
|
+
// The SDK synthesizes input-validation failures as isError RESULTS (no handler
|
|
4
|
+
// runs), so the client gets free prose; handler envelopes always start with "{".
|
|
8
5
|
const SCHEMA_VALIDATION_TEXT = /^(MCP error -32602: )?Input validation error:/;
|
|
9
6
|
export function isSchemaValidationText(text) {
|
|
10
7
|
return SCHEMA_VALIDATION_TEXT.test(text);
|
|
@@ -12,18 +9,11 @@ export function isSchemaValidationText(text) {
|
|
|
12
9
|
/** Capture form of the v2 text. The tool-name class excludes ':' and ' ', so
|
|
13
10
|
* the first ": " after the name is unambiguously the delimiter. */
|
|
14
11
|
const VALIDATION_PROSE = /^(?:MCP error -32602: )?Input validation error: Invalid arguments for tool ([A-Za-z0-9_.-]+): ([\s\S]+)$/;
|
|
15
|
-
/** The SDK's OTHER synthesized prose on the same catch
|
|
16
|
-
* SDK-authored
|
|
17
|
-
* (the account wizard answers in prose today). */
|
|
12
|
+
/** The SDK's OTHER synthesized prose on the same catch; a HANDLER's free text
|
|
13
|
+
* is not SDK-authored and must survive untouched. */
|
|
18
14
|
const SDK_OUTPUT_PROSE = /^(?:MCP error -32602: )?Output validation error: /;
|
|
19
|
-
// Wire-level
|
|
20
|
-
//
|
|
21
|
-
// on the -32602. A schema-level fix is off the table: the SDK advertises an
|
|
22
|
-
// EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
|
|
23
|
-
// only seam that keeps tools/list intact is the JSON-RPC message itself —
|
|
24
|
-
// which is versioned MCP spec, stabler than any SDK internal. The rename is
|
|
25
|
-
// provably lossless: it fires only when the sent key is NOT in the tool's
|
|
26
|
-
// schema, its camelCase twin IS, and that twin was not also sent.
|
|
15
|
+
// Wire-level, not schema-level: the SDK advertises an EMPTY input schema for
|
|
16
|
+
// any non-object wrapper, so the JSON-RPC message is the only stable seam. See docs/internals.md.
|
|
27
17
|
export function argNormalizationEnabled(env = process.env) {
|
|
28
18
|
return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
|
|
29
19
|
}
|
|
@@ -94,11 +84,8 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
|
|
|
94
84
|
return { action: 'forward', msg };
|
|
95
85
|
const tool = m.params.name;
|
|
96
86
|
const declared = opts.declaredFor(tool);
|
|
97
|
-
// Unregistered tool
|
|
98
|
-
//
|
|
99
|
-
// stays a JSON-RPC error; only the message gets the did-you-mean and the
|
|
100
|
-
// gated-service next step. Without `unknownTool`, or on a notification,
|
|
101
|
-
// the SDK's own "not found" still answers.
|
|
87
|
+
// Unregistered tool stays a PROTOCOL error (not an arguments problem); only
|
|
88
|
+
// the message gets the did-you-mean. No `unknownTool`, or a notification: the SDK's own answers.
|
|
102
89
|
if (!declared) {
|
|
103
90
|
if (!opts.unknownTool || m.id === undefined)
|
|
104
91
|
return { action: 'forward', msg };
|
|
@@ -115,8 +102,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
|
|
|
115
102
|
if (screened.unknown.length === 0 && screened.redundant.length === 0)
|
|
116
103
|
return { action: 'forward', msg };
|
|
117
104
|
const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
|
|
118
|
-
// Key names only
|
|
119
|
-
// caller's, so they are escaped here and only here: the envelope is JSON.
|
|
105
|
+
// Key names only, escaped here and only here: the caller's names, never values, reach the log.
|
|
120
106
|
log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
|
|
121
107
|
try {
|
|
122
108
|
// Only ever a DECLARED key or the literal placeholder, so the metrics
|
|
@@ -167,13 +153,10 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
|
|
|
167
153
|
if (outcome.action === 'forward')
|
|
168
154
|
return handler(outcome.msg, extra);
|
|
169
155
|
// Last-resort fallback: dispatch as before rather than hang the
|
|
170
|
-
// caller
|
|
171
|
-
// `send` is async, so a rejected promise needs catching too: a
|
|
172
|
-
// bare `void` left the client waiting for a frame that never came.
|
|
156
|
+
// caller; send is async, so a bare `void` would leave it waiting forever on rejection.
|
|
173
157
|
const fallback = () => handler(normalized, extra);
|
|
174
158
|
try {
|
|
175
|
-
//
|
|
176
|
-
// frame and clears its pending id.
|
|
159
|
+
// On the INNER transport so the metrics tap still sees the frame and clears its pending id.
|
|
177
160
|
void Promise.resolve(transport.send(outcome.response)).catch(fallback);
|
|
178
161
|
}
|
|
179
162
|
catch {
|
|
@@ -195,10 +178,8 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
|
|
|
195
178
|
if (transport.setProtocolVersion) {
|
|
196
179
|
wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
|
|
197
180
|
}
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
// eats a member the SDK calls is a bug waiting for the first caller that
|
|
201
|
-
// passes supportedProtocolVersions explicitly.
|
|
181
|
+
// A no-op today, but a proxy silently eating an SDK-called member is a bug
|
|
182
|
+
// waiting for the first caller that passes this explicitly.
|
|
202
183
|
if (transport.setSupportedProtocolVersions) {
|
|
203
184
|
wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
|
|
204
185
|
}
|
|
@@ -230,9 +211,8 @@ export function outputFailureEnvelope(tool, text, account) {
|
|
|
230
211
|
}
|
|
231
212
|
export function rewriteValidationFrame(message, pending, opts) {
|
|
232
213
|
const m = message;
|
|
233
|
-
// Responses only
|
|
234
|
-
//
|
|
235
|
-
// real pending call.
|
|
214
|
+
// Responses only: a server-initiated REQUEST also carries an id (a
|
|
215
|
+
// different id space), and evicting on it would drop a real pending call.
|
|
236
216
|
if (m.id === undefined || m.method !== undefined)
|
|
237
217
|
return message;
|
|
238
218
|
const call = pending.get(m.id);
|
package/dist/arg-strict.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
// Unknown-argument screening
|
|
2
|
-
//
|
|
3
|
-
// SUCCESS response with wrong behavior: `parentId` on drive_create_folder was
|
|
4
|
-
// dropped and the folder landed in My Drive root. Field-reported, then
|
|
5
|
-
// reproduced: the typo, the correct name and a wholly invented key all
|
|
6
|
-
// returned byte-identical responses.
|
|
7
|
-
//
|
|
8
|
-
// This module is pure and holds no registry reference. It never rewrites a key
|
|
9
|
-
// and never touches a VALUE: it only decides that a key is undeclared, and
|
|
10
|
-
// suggests what the caller probably meant. Rewriting would risk sending a real
|
|
11
|
-
// value to Google on a guess, which is strictly worse than refusing.
|
|
1
|
+
// Unknown-argument screening: zod strips undeclared keys before a handler
|
|
2
|
+
// runs, so a misremembered name used to produce a SUCCESS with wrong behavior. See docs/internals.md.
|
|
12
3
|
import { editDistance, SUGGEST_MAX_INPUT } from './scope-catalog.js';
|
|
13
4
|
import { sliceClean } from './trim.js';
|
|
14
5
|
/**
|
|
@@ -26,24 +17,15 @@ export function unknownArgMode(env = process.env) {
|
|
|
26
17
|
process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using warn\n`);
|
|
27
18
|
return 'warn';
|
|
28
19
|
}
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// zod before the handler runs, so `warn` means the CLIENT sees a confident
|
|
32
|
-
// success for a call the server did not perform: drive_list with a misspelled
|
|
33
|
-
// folder key returned the My Drive root, byte-identical to no argument at all.
|
|
34
|
-
// A wrong answer that reads as right is the failure mode agents recover from
|
|
35
|
-
// worst, so the default refuses the call and says what it probably meant.
|
|
20
|
+
// `warn` means the client sees a confident success for a call the server
|
|
21
|
+
// didn't perform; a wrong answer that reads as right is the worst failure mode to recover from.
|
|
36
22
|
const DEFAULT_MODE = 'reject';
|
|
37
23
|
/** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
|
|
38
24
|
* the escape hatch is NOT one, because its open-endedness lives in the VALUES
|
|
39
25
|
* of queryParams/body, never in its six fixed top-level keys. */
|
|
40
26
|
export const STRICT_EXEMPT_TOOLS = new Set();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* no arguments; the others are call-plumbing that bridges and proxies have
|
|
44
|
-
* been observed to fold into `arguments` instead of `params._meta`, where the
|
|
45
|
-
* spec puts them. Rejecting these would fail a call the model got right.
|
|
46
|
-
* Measured against the full tool surface: none is a declared key. */
|
|
27
|
+
// Keys a CLIENT adds on its own, not keys the model chose (a probe some
|
|
28
|
+
// clients send to argument-less tools, plus call-plumbing bridges fold into `arguments`).
|
|
47
29
|
const CLIENT_ARTIFACT_KEYS = new Set([
|
|
48
30
|
'random_string',
|
|
49
31
|
'toolCallId',
|
|
@@ -138,10 +120,8 @@ export function screenArguments(tool, args, declared) {
|
|
|
138
120
|
if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
|
|
139
121
|
return out;
|
|
140
122
|
const declaredSet = new Set(declared);
|
|
141
|
-
// Tier 0
|
|
142
|
-
//
|
|
143
|
-
// A near-miss duplicate (a typo sent next to its declared key) is recognised
|
|
144
|
-
// only within the budget: a call needs 9 of them before one is refused.
|
|
123
|
+
// Tier 0 is a map lookup, so a redundant twin is recognised past the
|
|
124
|
+
// ranking budget too; a near-miss duplicate still needs that budget.
|
|
145
125
|
const byFlat = new Map();
|
|
146
126
|
for (const k of declared)
|
|
147
127
|
byFlat.set(flatten(k), [...(byFlat.get(flatten(k)) ?? []), k]);
|
package/dist/auth.js
CHANGED
|
@@ -31,12 +31,8 @@ function parseCsvEnv(name) {
|
|
|
31
31
|
.map(s => s.trim())
|
|
32
32
|
.filter(Boolean);
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
* implicit "legacy-global" profile applied to every account (env wins over
|
|
37
|
-
* file profiles). Unknown names now fail loudly where v5
|
|
38
|
-
* silently dropped them — the intended migration signal.
|
|
39
|
-
*/
|
|
34
|
+
// A set GOOGLE_OPTIONAL_SCOPES acts as an implicit profile applied to every
|
|
35
|
+
// account (env wins over file profiles); unknown names fail loudly.
|
|
40
36
|
function legacyGlobalProfile() {
|
|
41
37
|
const names = parseCsvEnv('GOOGLE_OPTIONAL_SCOPES');
|
|
42
38
|
if (names.length === 0)
|
|
@@ -130,9 +126,8 @@ export async function runAuthFlow(args) {
|
|
|
130
126
|
// Auto-provisions on a fresh install (env > keychain > file > generate);
|
|
131
127
|
// resolves eagerly so a provisioning failure surfaces before the browser opens.
|
|
132
128
|
resolveMasterKey();
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// errors propagate to main()'s fatal handler like every other CLI failure.
|
|
129
|
+
// Listen first, then build the auth URL from the assigned redirect; errors
|
|
130
|
+
// propagate to main()'s fatal handler like every other CLI failure.
|
|
136
131
|
const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
|
|
137
132
|
const oauth2Client = buildConsentClient(loop.redirect);
|
|
138
133
|
// CSRF protection for the OAuth callback (RFC 6749 §10.12).
|
package/dist/boot-gates.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
// Multi-tenant boot gates
|
|
2
|
-
//
|
|
3
|
-
// installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
|
|
4
|
-
// resolve env at import time) so the tenancy module can import it cheaply.
|
|
1
|
+
// Multi-tenant boot gates: every helper reduces to single-owner behavior when
|
|
2
|
+
// no gates are installed. Deliberately side-effect-free, unlike accounts.ts/auth.ts.
|
|
5
3
|
import { failStartup } from './config-file.js';
|
|
6
4
|
let gates = null;
|
|
7
5
|
export function setMtBootGates(g) {
|
package/dist/cimd-cache.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// fetches running at once are capped, one client_id is fetched once however
|
|
4
|
-
// many requests wait on it, and only a document that passed validation is
|
|
5
|
-
// kept. Errors are never cached (CIMD section 5.2), and a negative cache would
|
|
6
|
-
// not help anyway: the attack varies the path, so its key never repeats.
|
|
1
|
+
// Bounds the unauthenticated-fetch surface of GET /authorize: capped
|
|
2
|
+
// in-flight fetches, one fetch per client_id, no error caching. See docs/internals.md.
|
|
7
3
|
import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
|
|
8
4
|
import { logSafe } from './trim.js';
|
|
9
5
|
export const CIMD_TTL_MS = 5 * 60_000;
|
|
@@ -69,16 +65,14 @@ export class CimdClientCache {
|
|
|
69
65
|
}
|
|
70
66
|
catch (e) {
|
|
71
67
|
out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// stays and answers this fetch's requests too (stale-if-error; the error
|
|
75
|
-
// itself is never stored).
|
|
68
|
+
// stale-if-error: an issuer that's down/overloaded says nothing about
|
|
69
|
+
// the document, so the last good one stays and serves this fetch too.
|
|
76
70
|
const stale = this.entries.get(clientId);
|
|
77
71
|
if (!saysNothingAboutDocument(e))
|
|
78
72
|
this.entries.delete(clientId);
|
|
79
73
|
else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
|
|
80
74
|
out = { doc: stale.doc, fresh: false };
|
|
81
|
-
//
|
|
75
|
+
// The detail goes to the log only; the caller gets a generic answer.
|
|
82
76
|
this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
|
|
83
77
|
}
|
|
84
78
|
finally {
|
package/dist/client-config.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// (detection + entry building + idempotent apply), deps-injectable for tests.
|
|
4
|
-
// Consent + non-TTY behavior live in the CLI/tool surfaces; secrets are NEVER
|
|
5
|
-
// inlined — the server self-loads its .env from ~/.config/mcp-google-multi, so
|
|
6
|
-
// a stdio entry needs no secret env at all.
|
|
1
|
+
// Registers the server with the user's MCP client so they never hand-edit
|
|
2
|
+
// JSON. Pure core (detection + entry building + idempotent apply), deps-injectable for tests.
|
|
7
3
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
8
4
|
import * as readline from 'node:readline';
|
|
9
5
|
import path from 'node:path';
|
|
@@ -20,9 +16,8 @@ function resolve(deps) {
|
|
|
20
16
|
mkdirp: deps.mkdirp ?? ((dir) => mkdirSync(dir, { recursive: true })),
|
|
21
17
|
};
|
|
22
18
|
}
|
|
23
|
-
// Build paths for the TARGET platform's separators, not the running host's,
|
|
24
|
-
// the injected `platform` is honored deterministically
|
|
25
|
-
// must still produce posix paths for a linux target, and vice versa).
|
|
19
|
+
// Build paths for the TARGET platform's separators, not the running host's,
|
|
20
|
+
// so the injected `platform` is honored deterministically.
|
|
26
21
|
function pp(r) {
|
|
27
22
|
return r.platform === 'win32' ? path.win32 : path.posix;
|
|
28
23
|
}
|
|
@@ -155,9 +150,8 @@ export function applyFileEntry(client, name, entry, deps = {}) {
|
|
|
155
150
|
const had = Object.prototype.hasOwnProperty.call(prevServers, name);
|
|
156
151
|
const next = { ...existing, mcpServers: { ...prevServers, [name]: entry } };
|
|
157
152
|
r.mkdirp(pp(r).dirname(p));
|
|
158
|
-
// Back up
|
|
159
|
-
//
|
|
160
|
-
// backup + direct write is sufficient — our own config.json uses fs-atomic).
|
|
153
|
+
// Back up before rewriting so a bad write is recoverable; our own
|
|
154
|
+
// config.json uses fs-atomic, but these small foreign configs don't need it.
|
|
161
155
|
let backup;
|
|
162
156
|
if (priorRaw !== null) {
|
|
163
157
|
backup = `${p}.bak`;
|
package/dist/client.js
CHANGED
|
Binary file
|
package/dist/compose.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// Registry composition, extracted from index.ts so
|
|
2
|
-
//
|
|
3
|
-
// module must stay free of import-time side effects of its own: no argv
|
|
4
|
-
// parsing, no process.exit, no prints (accounts.js module-load resolution is
|
|
5
|
-
// the one inherited, pre-existing exception on the import chain).
|
|
1
|
+
// Registry composition, extracted from index.ts so tests can build registries
|
|
2
|
+
// without touching the CLI entrypoint. No import-time side effects of its own.
|
|
6
3
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
7
4
|
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
8
5
|
import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
|
|
@@ -25,13 +22,9 @@ export function createMcpServer(version, opts = {}) {
|
|
|
25
22
|
}
|
|
26
23
|
export function buildRegistry(server, ctx, mode, metrics = null) {
|
|
27
24
|
const policy = ctx.policy;
|
|
28
|
-
// The registry's account view IS the context's
|
|
29
|
-
// selector validation and default-account injection all follow this
|
|
30
|
-
// context, not the process global (identical for the free core's one ctx).
|
|
25
|
+
// The registry's account view IS the context's, not the process global.
|
|
31
26
|
const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
|
|
32
|
-
//
|
|
33
|
-
// this context; ctx.accounts is read per call, never snapshotted here, so
|
|
34
|
-
// the owner's live getter stays live.
|
|
27
|
+
// ctx.accounts is read per call, never snapshotted here, so a live getter stays live.
|
|
35
28
|
const owner = isOwnerContext(ctx);
|
|
36
29
|
const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
|
|
37
30
|
const execDeps = {
|
package/dist/config-file.js
CHANGED
|
@@ -13,9 +13,8 @@ export function configFilePath(env = process.env) {
|
|
|
13
13
|
export function tenantsDir(env = process.env) {
|
|
14
14
|
return path.join(configDir(env), 'tenants');
|
|
15
15
|
}
|
|
16
|
-
// A tenant id becomes a filesystem path segment
|
|
17
|
-
//
|
|
18
|
-
// context's subject and client lane, so no tenant may take it.
|
|
16
|
+
// A tenant id becomes a filesystem path segment like an account alias (same
|
|
17
|
+
// traversal guard); 'owner' is the single-owner subject, so no tenant may take it.
|
|
19
18
|
function assertTenantId(tenantId) {
|
|
20
19
|
if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
|
|
21
20
|
throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
|
|
@@ -52,9 +51,7 @@ const accountEntrySchema = z.strictObject({
|
|
|
52
51
|
admin: z.boolean().optional(),
|
|
53
52
|
});
|
|
54
53
|
// .strict() everywhere: any unknown or secret-shaped key (clientSecret,
|
|
55
|
-
// masterKey, jwtKey, clientId) fails validation, structurally enforcing
|
|
56
|
-
// secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
|
|
57
|
-
// of the frozen envelope; their consumers land in later slices.
|
|
54
|
+
// masterKey, jwtKey, clientId) fails validation, structurally enforcing secrets-env-only.
|
|
58
55
|
export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
59
56
|
export function isReservedAlias(name) {
|
|
60
57
|
return RESERVED_ALIASES.includes(name);
|
|
@@ -72,9 +69,8 @@ const configSchema = z.strictObject({
|
|
|
72
69
|
defaultAccount: z.string().optional(),
|
|
73
70
|
discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
|
|
74
71
|
toolsets: z.string().optional(),
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// (strictObject); remove the key first.
|
|
72
|
+
// Absent = off. A pre-6.0 build reading a config carrying this key fails
|
|
73
|
+
// E_CONFIG_INVALID (strictObject); remove the key first to downgrade.
|
|
78
74
|
usageMetrics: z.boolean().optional(),
|
|
79
75
|
});
|
|
80
76
|
export function failStartup(slug, message) {
|
|
@@ -125,9 +121,8 @@ export function loadConfigFile(filePath = configFilePath(), onInvalid = 'exit')
|
|
|
125
121
|
const issue = result.error.issues[0];
|
|
126
122
|
fail('E_CONFIG_INVALID', `${filePath}: ${issue.path.join('.') || '(root)'}: ${issue.message}. Secrets never belong in config.json.`);
|
|
127
123
|
}
|
|
128
|
-
// Post-schema
|
|
129
|
-
//
|
|
130
|
-
// plain-object lookups downstream.
|
|
124
|
+
// Post-schema: zod's key regex passes these, but a __proto__ record key is
|
|
125
|
+
// a prototype-pollution foothold downstream.
|
|
131
126
|
const data = result.data;
|
|
132
127
|
for (const [section, keys] of [
|
|
133
128
|
['accounts', Object.keys(data.accounts ?? {})],
|
package/dist/discover.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { describePolicy } from './write-control.js';
|
|
3
3
|
import { hints } from './hint-provider.js';
|
|
4
|
-
// The description budget is the whole point of lazy mode:
|
|
5
|
-
//
|
|
6
|
-
// CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
|
|
4
|
+
// The description budget is the whole point of lazy mode: descriptions carry
|
|
5
|
+
// a CAPPED vocabulary, and the complete catalog stays in this tool's RESULT.
|
|
7
6
|
const CURATED_OPS_CAP = 10;
|
|
8
7
|
const GENERATED_GROUPS_CAP = 6;
|
|
9
8
|
function opVocabulary(registry, service) {
|
|
@@ -15,8 +14,7 @@ function opVocabulary(registry, service) {
|
|
|
15
14
|
parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
|
|
16
15
|
}
|
|
17
16
|
if (generated.length > 0) {
|
|
18
|
-
//
|
|
19
|
-
// more selective than any truncated name list.
|
|
17
|
+
// Compresses to resource groups: denser than any truncated name list.
|
|
20
18
|
const counts = new Map();
|
|
21
19
|
for (const op of generated) {
|
|
22
20
|
const group = op.split('_')[0];
|
|
@@ -31,10 +29,7 @@ function opVocabulary(registry, service) {
|
|
|
31
29
|
}
|
|
32
30
|
export function registerDiscoverTools(registry, policy) {
|
|
33
31
|
const registerMeta = registry.registerMeta;
|
|
34
|
-
//
|
|
35
|
-
// curated quality layer for heavy Google work, collapse to reclaim the
|
|
36
|
-
// name/schema context budget after. stdio-only semantics — over stateless
|
|
37
|
-
// HTTP the mode is forced curated and these are no-ops.
|
|
32
|
+
// stdio-only semantics: over stateless HTTP the mode is forced curated and these are no-ops.
|
|
38
33
|
registerMeta('discover_all', {
|
|
39
34
|
description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
|
|
40
35
|
'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
|
|
@@ -101,9 +96,8 @@ export function registerDiscoverTools(registry, policy) {
|
|
|
101
96
|
service,
|
|
102
97
|
operations,
|
|
103
98
|
writeControl: rendered?.hint ?? describePolicy(policy),
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
: `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
|
|
99
|
+
// catalog() falls back to a full one-line listing when nothing matches, so this is never empty.
|
|
100
|
+
next: 'Call the chosen tool by its exact name. A client that loads tools on demand may need its own tool-search or load step before the call succeeds.',
|
|
107
101
|
}),
|
|
108
102
|
},
|
|
109
103
|
],
|
package/dist/discovery-client.js
CHANGED
|
@@ -35,10 +35,8 @@ export const SUPPORTED_APIS = {
|
|
|
35
35
|
vault: { id: 'vault', version: 'v1' },
|
|
36
36
|
workspaceevents: { id: 'workspaceevents', version: 'v1' },
|
|
37
37
|
};
|
|
38
|
-
// Names agents actually type for an API, mapped to real SUPPORTED_APIS keys
|
|
39
|
-
//
|
|
40
|
-
// APIs, the Admin SDK is three); keep entries plural-target only when the
|
|
41
|
-
// split is real.
|
|
38
|
+
// Names agents actually type for an API, mapped to real SUPPORTED_APIS keys;
|
|
39
|
+
// plural-target entries only where the split ("analytics" is two APIs) is real.
|
|
42
40
|
export const API_ALIASES = {
|
|
43
41
|
analytics: ['analyticsadmin', 'analyticsdata'],
|
|
44
42
|
ga4: ['analyticsadmin', 'analyticsdata'],
|
|
@@ -74,9 +72,8 @@ export function discoveryCacheDir(env = process.env) {
|
|
|
74
72
|
return (env.DISCOVERY_CACHE_PATH ??
|
|
75
73
|
path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
|
|
76
74
|
}
|
|
77
|
-
// Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared
|
|
78
|
-
//
|
|
79
|
-
// effort. Clears group/other bits and keeps setuid/setgid/sticky.
|
|
75
|
+
// Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared
|
|
76
|
+
// and keeps its mode), only when this process owns it; clears group/other bits.
|
|
80
77
|
function tightenOwnDir(dir) {
|
|
81
78
|
if (process.platform === 'win32')
|
|
82
79
|
return;
|
|
@@ -110,9 +107,8 @@ export function buildMethodIndex(doc, api) {
|
|
|
110
107
|
if (!method.id || !method.httpMethod || !method.path)
|
|
111
108
|
continue;
|
|
112
109
|
const params = method.parameters ?? {};
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// tie-break) to keep gen-tools output deterministic.
|
|
110
|
+
// parameters{} has unstable key order between fetches; sort by the
|
|
111
|
+
// canonical parameterOrder (alphabetical tie-break) for deterministic output.
|
|
116
112
|
const order = method.parameterOrder ?? [];
|
|
117
113
|
const pos = (n) => {
|
|
118
114
|
const i = order.indexOf(n);
|
|
@@ -174,9 +170,8 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
174
170
|
if (cacheFresh())
|
|
175
171
|
doc = readCache();
|
|
176
172
|
if (!doc) {
|
|
177
|
-
// Newer APIs
|
|
178
|
-
//
|
|
179
|
-
// authoritative, so try both — same fallback as scripts/fetch-discovery.
|
|
173
|
+
// Newer APIs are absent from the central discovery directory (404); try
|
|
174
|
+
// each service's own $discovery endpoint too, as scripts/fetch-discovery does.
|
|
180
175
|
const urls = [
|
|
181
176
|
`https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
|
|
182
177
|
`https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
|
|
@@ -217,20 +212,11 @@ export function clearDiscoveryMemoryCache() {
|
|
|
217
212
|
// GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
|
|
218
213
|
// and check* predicates are POSTs purely for the request-body size — reads.
|
|
219
214
|
const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|suggest|(batch)?run\w*report)/i;
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// before delete for exactly that reason.
|
|
223
|
-
// State transitions on something that already exists: reversible, and none of
|
|
224
|
-
// them creates anything, so `create` was both wrong and the most permissive
|
|
225
|
-
// class available. `(batch)?` mirrors the delete list, without which every
|
|
226
|
-
// `batchUpdate*` POST fell through to `create`.
|
|
215
|
+
// Update, tested before delete, so cancelWipe (calling off a wipe) doesn't
|
|
216
|
+
// reach the delete list via its "cancel" suffix. See docs/internals.md.
|
|
227
217
|
const POST_UPDATE_VERB = /^(cancelWipe|(batch)?(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format|change|close|reopen|enable|disable|hide|unhide|accept|approve|decline|reassign|reactivate|renew|mark|turn|suspend|activate|make|return|reclaim|complete))/i;
|
|
228
|
-
// archive
|
|
229
|
-
//
|
|
230
|
-
// So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
|
|
231
|
-
// long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
|
|
232
|
-
// codes and sessions, `reset` discards a configuration, and `end` terminates a
|
|
233
|
-
// live conference. Each removes something that existed.
|
|
218
|
+
// archive/wipe/reset/stop/cancel/end etc. all remove or permanently discard
|
|
219
|
+
// something that existed, so the most restrictive class is the safe one. See docs/internals.md.
|
|
234
220
|
const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive|wipe|invalidate|signOut|stop|cancel|unregister|unreserve|reset|end)/i;
|
|
235
221
|
export function cudFromMethod(method) {
|
|
236
222
|
switch (method.httpMethod) {
|
|
@@ -262,13 +248,8 @@ export function expandPath(template, pathParams) {
|
|
|
262
248
|
if (value === undefined) {
|
|
263
249
|
throw new Error(`Missing required path parameter "${name}"`);
|
|
264
250
|
}
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
// back as the whole site LIST, reported as success. Dot segments resolve
|
|
268
|
-
// at URL-parse time, so `../v1/contactGroups` retargets the call at a
|
|
269
|
-
// different endpoint and returns ITS response as success. Both are
|
|
270
|
-
// silent-wrong, so refuse locally rather than send a different request.
|
|
271
|
-
// `{+param}` keeps its slashes, so every segment has to be checked.
|
|
251
|
+
// Both a blank segment and a "." / ".." segment silently retarget the
|
|
252
|
+
// request to a different, successfully-answered endpoint. See docs/internals.md.
|
|
272
253
|
const segments = plus ? String(value).split('/') : [String(value)];
|
|
273
254
|
for (const segment of segments) {
|
|
274
255
|
if (segment.trim() === '') {
|
package/dist/doctor.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface HttpProbeResult {
|
|
|
55
55
|
/** Console deep-link to enable one API (section-6 hint). */
|
|
56
56
|
export declare function apiEnableLink(api: string): string;
|
|
57
57
|
/** One line, state AND source, so the people being measured can see both
|
|
58
|
-
* here and in `diagnose
|
|
58
|
+
* here and in `diagnose`. Read-only. */
|
|
59
59
|
export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
|
|
60
60
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
61
61
|
export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
|