mcp-google-multi 6.0.0-beta.3 → 6.0.0-beta.4
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 -5
- package/dist/accounts.d.ts +3 -3
- package/dist/accounts.js +18 -31
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +5 -10
- package/dist/boot-gates.d.ts +1 -1
- package/dist/boot-gates.js +3 -5
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +8 -1
- package/dist/compose.js +11 -11
- package/dist/config-file.js +7 -12
- package/dist/discover.js +4 -9
- package/dist/discovery-client.js +14 -33
- package/dist/doctor.d.ts +3 -3
- package/dist/doctor.js +12 -18
- 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.js +4 -10
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +5 -5
- package/dist/http-transport.js +39 -92
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +6 -9
- package/dist/index.js +19 -31
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +11 -22
- package/dist/mcp-token.d.ts +3 -13
- package/dist/mcp-token.js +16 -32
- 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 +3 -5
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +2 -2
- package/dist/oauth-as.js +32 -73
- 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.d.ts +1 -1
- package/dist/registry-transfer.js +5 -13
- package/dist/registry.d.ts +1 -9
- package/dist/registry.js +38 -66
- package/dist/scope-catalog.d.ts +5 -0
- package/dist/scope-catalog.js +17 -7
- package/dist/scope-observability.d.ts +2 -3
- 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 -22
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.js +22 -39
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.d.ts +4 -3
- package/dist/tools/account-wizard.js +48 -54
- package/dist/tools/accounts-tool.d.ts +1 -1
- package/dist/tools/accounts-tool.js +1 -1
- 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 +8 -9
- package/dist/tools/docs.js +4 -9
- package/dist/tools/drive.js +18 -27
- package/dist/tools/forms.js +2 -3
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +2 -2
- package/dist/tools/gmail.js +26 -40
- package/dist/tools/google-api.js +5 -7
- 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 +4 -8
- package/dist/write-control.js +3 -4
- package/package.json +21 -21
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ New to all this? It's written for someone who just installed Claude Code and has
|
|
|
44
44
|
5. **Add it to Claude Code, then restart Claude Code:**
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
claude mcp add google-multi -s user -- npx -y mcp-google-multi
|
|
47
|
+
claude mcp add mcp-google-multi -s user -- npx -y mcp-google-multi
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
**Stuck at any point? Run `mcp-google-multi doctor`.** It inspects every part and prints the exact fix for anything wrong (a missing sign-in, a Google API you still need to switch on, and so on). Once it reads all-green, just talk to Claude: *"summarize my unread email."*
|
|
@@ -55,14 +55,14 @@ New to all this? It's written for someone who just installed Claude Code and has
|
|
|
55
55
|
|
|
56
56
|
**Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Remote / HTTP setup](./docs/http-setup.md) · [Secrets in a vault](./docs/secrets.md) · [Local usage metrics](./docs/usage-metrics.md) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
|
|
57
57
|
|
|
58
|
-
## Maintainer &
|
|
58
|
+
## Maintainer, sponsor & license
|
|
59
59
|
|
|
60
60
|
Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.xyz) · [@bakissation](https://github.com/bakissation). [Read the case study →](https://berkati.xyz/case-studies/mcp-google-multi/)
|
|
61
61
|
|
|
62
62
|
Development is **funded by [IdeaCrafters](https://ideacrafters.com)** ([@IdeaCraftersHQ](https://github.com/IdeaCraftersHQ)) — the studio that pays for this OSS to exist.
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
An Enterprise Edition (hosted, multi-tenant) is available: ask through [berkati.xyz](https://berkati.xyz) (email or LinkedIn there).
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
Thanks to contributors [@obatried](https://github.com/obatried), [@trevor-commits](https://github.com/trevor-commits), and [@mjreddy](https://github.com/mjreddy). The project is maintainer-led (roadmap on [Milestones](https://github.com/bakissation/mcp-google-multi/milestones); bug reports welcome, feature PRs by prior agreement — see [CONTRIBUTING.md](./CONTRIBUTING.md)). **Feedback shapes the roadmap: [open an issue](https://github.com/bakissation/mcp-google-multi/issues/new/choose) with bugs, pain points, or what you wish it did.** Security reports go to [SECURITY.md](./SECURITY.md), never a public issue.
|
|
67
67
|
|
|
68
|
-
[MIT](./LICENSE)
|
|
68
|
+
Licensed under [MIT](./LICENSE).
|
package/dist/accounts.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare function getAccountSet(): AccountSet;
|
|
|
28
28
|
/** Re-resolve after a config.json mutation and swap the live set. */
|
|
29
29
|
export declare function invalidateAccountSet(): AccountSet;
|
|
30
30
|
export declare function refreshAccountSetIfStale(): void;
|
|
31
|
-
/** Cross-process staleness probe
|
|
31
|
+
/** Cross-process staleness probe: one stat, compared against the stamp. */
|
|
32
32
|
export declare function isAccountSetStale(): boolean;
|
|
33
33
|
/** Account aliases (possibly empty on a fresh install).
|
|
34
34
|
* Snapshot from the initial load; enums widen only when the registry is
|
|
@@ -43,7 +43,7 @@ export declare function unknownAliasMessage(aliases: readonly string[], selector
|
|
|
43
43
|
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
44
44
|
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
|
45
45
|
* (module load) and take down every CLI — including `doctor`, which is meant to
|
|
46
|
-
* REPORT the empty registry
|
|
46
|
+
* REPORT the empty registry. Fall back to a plain string when there
|
|
47
47
|
* are no aliases: no alias exists to enumerate, the server refuses to boot empty
|
|
48
48
|
* anyway, and dispatch validates the account against the live set. */
|
|
49
49
|
export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
|
|
@@ -62,7 +62,7 @@ export declare function liveAccountCheck(aliases: () => readonly string[], selec
|
|
|
62
62
|
/** True for a field built on liveAccountCheck, optional or not. */
|
|
63
63
|
export declare function isLiveAccountField(field: unknown): boolean;
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
65
|
+
* The stdio/http SERVER never boots with an empty registry — a fresh user
|
|
66
66
|
* bootstraps via env / `migrate-config` / `account import` / `auth` first. The
|
|
67
67
|
* bootstrap and diagnostic CLIs return before this guard, so it gates only the
|
|
68
68
|
* server path (called from index.ts after the CLI branches). */
|
package/dist/accounts.js
CHANGED
|
@@ -67,7 +67,7 @@ function noAccountsMessage() {
|
|
|
67
67
|
`or set GOOGLE_ACCOUNTS=work:user@company.com,personal:user@gmail.com in the environment.${envHint}`);
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* Registry resolution
|
|
70
|
+
* Registry resolution: a non-empty GOOGLE_ACCOUNTS env takes the WHOLE
|
|
71
71
|
* registry from env (12-factor override, never merged key-by-key); otherwise
|
|
72
72
|
* the registry comes from config.json. GOOGLE_ADMIN_ACCOUNTS, when NON-EMPTY,
|
|
73
73
|
* overrides per-account admin flags from the file (env var > config field);
|
|
@@ -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,9 +85,8 @@ opts = {}) {
|
|
|
86
85
|
throw new Error(`${slug}: ${message}`);
|
|
87
86
|
failStartup(slug, message);
|
|
88
87
|
};
|
|
89
|
-
// Validate
|
|
90
|
-
//
|
|
91
|
-
// account_list instead of the promised startup error (BR3).
|
|
88
|
+
// Validate at BOOT, or a typo only surfaces at dispatch time inside
|
|
89
|
+
// account_list instead of the promised startup error.
|
|
92
90
|
const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
|
|
93
91
|
if (legacyNames.length > 0) {
|
|
94
92
|
for (const bundle of resolveBundleAliases(legacyNames)) {
|
|
@@ -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 (BR-4), 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 (BR-7) 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
|
-
//
|
|
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;
|
|
@@ -251,7 +238,7 @@ export function invalidateAccountSet() {
|
|
|
251
238
|
return current;
|
|
252
239
|
}
|
|
253
240
|
/**
|
|
254
|
-
* Dispatch-path reload
|
|
241
|
+
* Dispatch-path reload: NEVER exits and never throws — a mid-edit,
|
|
255
242
|
* corrupt, or deleted config.json keeps the last-good set and warns once per
|
|
256
243
|
* distinct failure on stderr. failStartup semantics are boot/CLI-only.
|
|
257
244
|
*/
|
|
@@ -271,7 +258,7 @@ export function refreshAccountSetIfStale() {
|
|
|
271
258
|
}
|
|
272
259
|
}
|
|
273
260
|
}
|
|
274
|
-
/** Cross-process staleness probe
|
|
261
|
+
/** Cross-process staleness probe: one stat, compared against the stamp. */
|
|
275
262
|
export function isAccountSetStale() {
|
|
276
263
|
if (current.source !== 'file')
|
|
277
264
|
return false;
|
|
@@ -297,7 +284,7 @@ export function unknownAliasMessage(aliases, selectors = false) {
|
|
|
297
284
|
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
298
285
|
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
|
299
286
|
* (module load) and take down every CLI — including `doctor`, which is meant to
|
|
300
|
-
* REPORT the empty registry
|
|
287
|
+
* REPORT the empty registry. Fall back to a plain string when there
|
|
301
288
|
* are no aliases: no alias exists to enumerate, the server refuses to boot empty
|
|
302
289
|
* anyway, and dispatch validates the account against the live set. */
|
|
303
290
|
export function accountAliasSchemaFor(aliases) {
|
|
@@ -339,7 +326,7 @@ export function isLiveAccountField(field) {
|
|
|
339
326
|
return def?.checks?.some((c) => LIVE_ACCOUNT_CHECKS.has(c)) ?? false;
|
|
340
327
|
}
|
|
341
328
|
/**
|
|
342
|
-
*
|
|
329
|
+
* The stdio/http SERVER never boots with an empty registry — a fresh user
|
|
343
330
|
* bootstraps via env / `migrate-config` / `account import` / `auth` first. The
|
|
344
331
|
* bootstrap and diagnostic CLIs return before this guard, so it gates only the
|
|
345
332
|
* server path (called from index.ts after the CLI branches). */
|
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 978-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.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export declare const BASE_SCOPES: string[];
|
|
|
4
4
|
export declare const OPTIONAL_SCOPE_BUNDLES: Record<string, string[]>;
|
|
5
5
|
export { ADMIN_SCOPES };
|
|
6
6
|
/** Union of every account's resolved bundles: a service registers if ANY
|
|
7
|
-
* account can authorize it; per-account authz happens at call time
|
|
7
|
+
* account can authorize it; per-account authz happens at call time. */
|
|
8
8
|
export declare function getOptionalBundles(set?: AccountSet): string[];
|
|
9
9
|
/** Aliases granted ADMIN_SCOPES: per-account admin flag (env
|
|
10
10
|
* GOOGLE_ADMIN_ACCOUNTS overrides config.json at resolve) OR the account's
|
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, cc-config R2). Unknown names now fail loudly (BR3) 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)
|
|
@@ -65,7 +61,7 @@ function profileForAccount(alias, set = getAccountSet()) {
|
|
|
65
61
|
return Object.hasOwn(set.scopeProfiles, name) ? set.scopeProfiles[name] : { bundles: [] };
|
|
66
62
|
}
|
|
67
63
|
/** Union of every account's resolved bundles: a service registers if ANY
|
|
68
|
-
* account can authorize it; per-account authz happens at call time
|
|
64
|
+
* account can authorize it; per-account authz happens at call time. */
|
|
69
65
|
export function getOptionalBundles(set = getAccountSet()) {
|
|
70
66
|
const legacy = legacyGlobalProfile();
|
|
71
67
|
if (legacy)
|
|
@@ -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.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface MtBootGates {
|
|
|
11
11
|
export declare function setMtBootGates(g: MtBootGates | null): void;
|
|
12
12
|
export declare function mtBootGates(): MtBootGates | null;
|
|
13
13
|
export declare function isMultiTenantBoot(): boolean;
|
|
14
|
-
/**
|
|
14
|
+
/** The empty-registry guard applies to stdio always, and to HTTP only when single-owner: a
|
|
15
15
|
* multi-tenant HTTP deploy starts empty by design. */
|
|
16
16
|
export declare function accountsAssertRequired(wantStdio: boolean, multiTenant: boolean): boolean;
|
|
17
17
|
/** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
|
package/dist/boot-gates.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
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) {
|
|
@@ -13,7 +11,7 @@ export function mtBootGates() {
|
|
|
13
11
|
export function isMultiTenantBoot() {
|
|
14
12
|
return gates?.multiTenant === true;
|
|
15
13
|
}
|
|
16
|
-
/**
|
|
14
|
+
/** The empty-registry guard applies to stdio always, and to HTTP only when single-owner: a
|
|
17
15
|
* multi-tenant HTTP deploy starts empty by design. */
|
|
18
16
|
export function accountsAssertRequired(wantStdio, multiTenant) {
|
|
19
17
|
return wantStdio || !multiTenant;
|
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.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface EntryOptions {
|
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* The client `mcpServers` entry. stdio needs no secret env: the server loads
|
|
41
|
-
* its own .env from ~/.config/mcp-google-multi
|
|
41
|
+
* its own .env from ~/.config/mcp-google-multi, so we never
|
|
42
42
|
* inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
|
|
43
43
|
*/
|
|
44
44
|
export declare function buildServerEntry(opts: EntryOptions): ServerEntry;
|
|
@@ -73,7 +73,7 @@ export type ApplyResult = {
|
|
|
73
73
|
* the prior file, update-in-place if the key exists.
|
|
74
74
|
*/
|
|
75
75
|
export declare function applyFileEntry(client: ClientInfo, name: string, entry: ServerEntry, deps?: ClientDeps): ApplyResult;
|
|
76
|
-
/** Resolve stdio-vs-http mode from the current transport config
|
|
76
|
+
/** Resolve stdio-vs-http mode from the current transport config.
|
|
77
77
|
* http/both → a `url` entry; stdio → a `command` entry. */
|
|
78
78
|
export declare function resolveMode(httpConfig?: {
|
|
79
79
|
transport: 'stdio' | 'http' | 'both';
|
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 (distribution.md B15).
|
|
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
|
}
|
|
@@ -78,7 +73,7 @@ export function detectClients(deps = {}) {
|
|
|
78
73
|
}
|
|
79
74
|
/**
|
|
80
75
|
* The client `mcpServers` entry. stdio needs no secret env: the server loads
|
|
81
|
-
* its own .env from ~/.config/mcp-google-multi
|
|
76
|
+
* its own .env from ~/.config/mcp-google-multi, so we never
|
|
82
77
|
* inline MASTER_KEY / GOOGLE_CLIENT_SECRET into a world-readable client config.
|
|
83
78
|
*/
|
|
84
79
|
export function buildServerEntry(opts) {
|
|
@@ -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`;
|
|
@@ -166,7 +160,7 @@ export function applyFileEntry(client, name, entry, deps = {}) {
|
|
|
166
160
|
r.writeFile(p, `${JSON.stringify(next, null, 2)}\n`);
|
|
167
161
|
return { ok: true, client: client.id, action: had ? 'updated' : 'added', path: p, backup };
|
|
168
162
|
}
|
|
169
|
-
/** Resolve stdio-vs-http mode from the current transport config
|
|
163
|
+
/** Resolve stdio-vs-http mode from the current transport config.
|
|
170
164
|
* http/both → a `url` entry; stdio → a `command` entry. */
|
|
171
165
|
export function resolveMode(httpConfig) {
|
|
172
166
|
if (httpConfig && (httpConfig.transport === 'http' || httpConfig.transport === 'both')) {
|
package/dist/client.js
CHANGED
|
Binary file
|
package/dist/compose.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { ServerTarget } from './http-transport.js';
|
|
3
3
|
import { ToolRegistry, type DiscoveryMode } from './registry.js';
|
|
4
4
|
import { type IdentityContext } from './identity.js';
|
|
5
5
|
import type { Metrics } from './usage-metrics.js';
|
|
6
|
+
export interface McpServerOptions {
|
|
7
|
+
/** Sent in the initialize result; omitted entirely when absent or blank. */
|
|
8
|
+
instructions?: string;
|
|
9
|
+
}
|
|
10
|
+
/** The one place the MCP server is constructed, so an embedding host can pass
|
|
11
|
+
* per-session `instructions` without re-deriving the server identity. */
|
|
12
|
+
export declare function createMcpServer(version: string, opts?: McpServerOptions): McpServer;
|
|
6
13
|
export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
|
|
7
14
|
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
8
15
|
* argument screening and the account a validation envelope names. Both
|
package/dist/compose.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
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.
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
6
4
|
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
7
5
|
import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
|
|
8
6
|
import { argNormalizationEnabled } from './arg-normalize.js';
|
|
@@ -16,15 +14,17 @@ import { registerAccountWizardTools } from './tools/account-wizard.js';
|
|
|
16
14
|
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
17
15
|
import { isOwnerContext } from './identity.js';
|
|
18
16
|
import { resolveScopesForAccount } from './auth.js';
|
|
17
|
+
/** The one place the MCP server is constructed, so an embedding host can pass
|
|
18
|
+
* per-session `instructions` without re-deriving the server identity. */
|
|
19
|
+
export function createMcpServer(version, opts = {}) {
|
|
20
|
+
const instructions = opts.instructions?.trim() ? opts.instructions : undefined;
|
|
21
|
+
return new McpServer({ name: 'mcp-google-multi', version }, instructions ? { instructions } : undefined);
|
|
22
|
+
}
|
|
19
23
|
export function buildRegistry(server, ctx, mode, metrics = null) {
|
|
20
24
|
const policy = ctx.policy;
|
|
21
|
-
// The registry's account view IS the context's
|
|
22
|
-
// selector validation and default-account injection all follow this
|
|
23
|
-
// 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.
|
|
24
26
|
const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
|
|
25
|
-
//
|
|
26
|
-
// this context; ctx.accounts is read per call, never snapshotted here, so
|
|
27
|
-
// the owner's live getter stays live.
|
|
27
|
+
// ctx.accounts is read per call, never snapshotted here, so a live getter stays live.
|
|
28
28
|
const owner = isOwnerContext(ctx);
|
|
29
29
|
const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
|
|
30
30
|
const execDeps = {
|