mcp-google-multi 6.0.0-beta.2 → 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 +20 -4
- package/dist/accounts.js +68 -44
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +19 -38
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +38 -28
- package/dist/auth.d.ts +5 -4
- package/dist/auth.js +16 -23
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +36 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +111 -0
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +18 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +41 -10
- package/dist/discover.js +7 -10
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +36 -32
- package/dist/doctor.d.ts +9 -5
- package/dist/doctor.js +119 -29
- package/dist/env-load.js +2 -5
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +6 -6
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +47 -22
- package/dist/fs-atomic.d.ts +8 -0
- package/dist/fs-atomic.js +216 -29
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +58 -0
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +67 -9
- package/dist/http-transport.js +318 -52
- package/dist/identity.d.ts +6 -3
- package/dist/identity.js +24 -8
- package/dist/index.js +76 -160
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +16 -19
- package/dist/mcp-token.d.ts +126 -19
- package/dist/mcp-token.js +557 -76
- 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 +5 -7
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +104 -4
- package/dist/oauth-as.js +368 -114
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +20 -10
- package/dist/outbound-allowlist.js +9 -11
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +11 -10
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +63 -11
- package/dist/registry.d.ts +13 -4
- package/dist/registry.js +100 -72
- package/dist/scope-catalog.d.ts +16 -0
- package/dist/scope-catalog.js +40 -13
- package/dist/scope-observability.d.ts +10 -9
- package/dist/scope-observability.js +34 -24
- package/dist/services.d.ts +18 -10
- package/dist/services.js +61 -22
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +106 -78
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +65 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +72 -60
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +22 -8
- package/dist/tools/account-wizard.d.ts +13 -3
- package/dist/tools/account-wizard.js +107 -56
- package/dist/tools/accounts-tool.d.ts +8 -1
- package/dist/tools/accounts-tool.js +35 -19
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +14 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +14 -10
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +19 -18
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +12 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +22 -19
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +35 -36
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +80 -74
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +18 -12
- package/dist/tools/generated/_shared.d.ts +2 -1
- package/dist/tools/generated/_shared.js +7 -3
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +265 -265
- package/dist/tools/generated/analytics.d.ts +2 -1
- package/dist/tools/generated/analytics.js +125 -125
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +5 -5
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +55 -55
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +95 -95
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +209 -209
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +133 -133
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +99 -99
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +29 -29
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +71 -71
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +53 -53
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +117 -117
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +3 -3
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +7 -7
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +15 -15
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +15 -15
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +27 -27
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +9 -9
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +35 -35
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +33 -33
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +3 -3
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +15 -15
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +5 -5
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +67 -67
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +31 -31
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +4 -3
- package/dist/tools/gmail.js +67 -69
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +34 -22
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +12 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +16 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +35 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +16 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +18 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +19 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +67 -56
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +13 -6
- package/package.json +47 -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
|
@@ -18,13 +18,17 @@ export interface AccountSet {
|
|
|
18
18
|
defaultAccount?: string;
|
|
19
19
|
defaultAccountSource?: 'env' | 'config';
|
|
20
20
|
}
|
|
21
|
-
export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw'
|
|
21
|
+
export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw', opts?: {
|
|
22
|
+
tokenDir?: string;
|
|
23
|
+
emptyOk?: boolean;
|
|
24
|
+
}): AccountSet;
|
|
25
|
+
export declare function fileStamp(filePath: string, version: number): string;
|
|
22
26
|
/** Live accessor: dispatch-time readers use this, never a captured snapshot. */
|
|
23
27
|
export declare function getAccountSet(): AccountSet;
|
|
24
28
|
/** Re-resolve after a config.json mutation and swap the live set. */
|
|
25
29
|
export declare function invalidateAccountSet(): AccountSet;
|
|
26
30
|
export declare function refreshAccountSetIfStale(): void;
|
|
27
|
-
/** Cross-process staleness probe
|
|
31
|
+
/** Cross-process staleness probe: one stat, compared against the stamp. */
|
|
28
32
|
export declare function isAccountSetStale(): boolean;
|
|
29
33
|
/** Account aliases (possibly empty on a fresh install).
|
|
30
34
|
* Snapshot from the initial load; enums widen only when the registry is
|
|
@@ -39,14 +43,26 @@ export declare function unknownAliasMessage(aliases: readonly string[], selector
|
|
|
39
43
|
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
40
44
|
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
|
41
45
|
* (module load) and take down every CLI — including `doctor`, which is meant to
|
|
42
|
-
* REPORT the empty registry
|
|
46
|
+
* REPORT the empty registry. Fall back to a plain string when there
|
|
43
47
|
* are no aliases: no alias exists to enumerate, the server refuses to boot empty
|
|
44
48
|
* anyway, and dispatch validates the account against the live set. */
|
|
45
49
|
export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
|
|
46
50
|
/** Shared, load-time snapshot used by every tool's `account` field. */
|
|
47
51
|
export declare const accountAliasSchema: z.ZodType<string>;
|
|
52
|
+
/** Live account argument: validates at PARSE time against the accessor's
|
|
53
|
+
* CURRENT aliases, so an alias added mid-session (account_add, or a tenant
|
|
54
|
+
* alias link) is immediately valid on already-registered tools — a baked
|
|
55
|
+
* z.enum freezes the boot-time set until restart. An empty set stays
|
|
56
|
+
* permissive (same empty-safe rule as above); dispatch still resolves the
|
|
57
|
+
* alias against the live registry. */
|
|
58
|
+
export declare function accountArgLive(aliases: () => readonly string[]): z.ZodType<string>;
|
|
59
|
+
/** The live membership check behind accountArgLive and the fan-out field;
|
|
60
|
+
* `selectors` also admits "*". An empty set stays permissive. */
|
|
61
|
+
export declare function liveAccountCheck(aliases: () => readonly string[], selectors: boolean): z.core.$ZodCheck<string>;
|
|
62
|
+
/** True for a field built on liveAccountCheck, optional or not. */
|
|
63
|
+
export declare function isLiveAccountField(field: unknown): boolean;
|
|
48
64
|
/**
|
|
49
|
-
*
|
|
65
|
+
* The stdio/http SERVER never boots with an empty registry — a fresh user
|
|
50
66
|
* bootstraps via env / `migrate-config` / `account import` / `auth` first. The
|
|
51
67
|
* bootstrap and diagnostic CLIs return before this guard, so it gates only the
|
|
52
68
|
* server path (called from index.ts after the CLI branches). */
|
package/dist/accounts.js
CHANGED
|
@@ -2,8 +2,8 @@ import path from 'node:path';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { loadEnvFiles } from './env-load.js';
|
|
5
|
-
import { CONFIG_VERSION, configDir, configFilePath, failStartup, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
6
|
-
import {
|
|
5
|
+
import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
6
|
+
import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
7
7
|
const envLoad = loadEnvFiles();
|
|
8
8
|
const defaultTokenDir = path.join(configDir(), 'tokens');
|
|
9
9
|
const tokenDir = process.env.TOKEN_STORE_PATH
|
|
@@ -15,14 +15,14 @@ export function getTokenDir() {
|
|
|
15
15
|
function parseCsv(value) {
|
|
16
16
|
return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
17
17
|
}
|
|
18
|
-
function accountPaths(alias) {
|
|
18
|
+
function accountPaths(alias, dir = tokenDir) {
|
|
19
19
|
return {
|
|
20
|
-
tokenPath: path.join(
|
|
21
|
-
encPath: path.join(
|
|
20
|
+
tokenPath: path.join(dir, alias, 'token.json'),
|
|
21
|
+
encPath: path.join(dir, `${alias}.enc`),
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
/** v5 env parser, guards verbatim. Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
|
|
25
|
-
function parseEnvAccounts(raw, adminAliases) {
|
|
25
|
+
function parseEnvAccounts(raw, adminAliases, dir = tokenDir) {
|
|
26
26
|
const configs = {};
|
|
27
27
|
const aliases = [];
|
|
28
28
|
for (const entry of raw.split(',')) {
|
|
@@ -40,7 +40,7 @@ function parseEnvAccounts(raw, adminAliases) {
|
|
|
40
40
|
}
|
|
41
41
|
// Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
|
|
42
42
|
// (e.g. "../../etc/passwd:foo@bar.com" in .env).
|
|
43
|
-
if (
|
|
43
|
+
if (!ALIAS_RE.test(alias) || isReservedAlias(alias)) {
|
|
44
44
|
throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
|
|
45
45
|
}
|
|
46
46
|
if (aliases.includes(alias)) {
|
|
@@ -49,7 +49,7 @@ function parseEnvAccounts(raw, adminAliases) {
|
|
|
49
49
|
aliases.push(alias);
|
|
50
50
|
configs[alias] = {
|
|
51
51
|
email,
|
|
52
|
-
...accountPaths(alias),
|
|
52
|
+
...accountPaths(alias, dir),
|
|
53
53
|
admin: adminAliases.includes(alias) || undefined,
|
|
54
54
|
source: 'env',
|
|
55
55
|
};
|
|
@@ -67,14 +67,17 @@ 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);
|
|
74
74
|
* empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
|
|
75
75
|
*/
|
|
76
76
|
let warnedLegacy = false;
|
|
77
|
-
export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit'
|
|
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
|
+
opts = {}) {
|
|
80
|
+
const dir = opts.tokenDir ?? tokenDir;
|
|
78
81
|
const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
|
|
79
82
|
const rawEnv = env.GOOGLE_ACCOUNTS;
|
|
80
83
|
const fail = (slug, message) => {
|
|
@@ -82,16 +85,15 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
82
85
|
throw new Error(`${slug}: ${message}`);
|
|
83
86
|
failStartup(slug, message);
|
|
84
87
|
};
|
|
85
|
-
// Validate
|
|
86
|
-
//
|
|
87
|
-
// 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.
|
|
88
90
|
const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
|
|
89
91
|
if (legacyNames.length > 0) {
|
|
90
92
|
for (const bundle of resolveBundleAliases(legacyNames)) {
|
|
91
93
|
if (bundle === 'admin') {
|
|
92
94
|
fail('E_UNKNOWN_BUNDLE', '"admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
|
|
93
95
|
}
|
|
94
|
-
if (!(bundle
|
|
96
|
+
if (!isKnownBundle(bundle)) {
|
|
95
97
|
const hint = closestBundle(bundle);
|
|
96
98
|
fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
97
99
|
}
|
|
@@ -102,7 +104,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
if (rawEnv && rawEnv.trim() !== '') {
|
|
105
|
-
const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv);
|
|
107
|
+
const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv, dir);
|
|
106
108
|
materializeFirstRun(aliases, configs, filePath);
|
|
107
109
|
const def = resolveDefaultAccount(env, null, aliases, fail);
|
|
108
110
|
return {
|
|
@@ -116,20 +118,14 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
116
118
|
...def,
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
|
-
// Stat BEFORE read
|
|
120
|
-
// stamp conservative (flags stale again next dispatch) instead of pinning
|
|
121
|
-
// stale content behind a fresh stamp.
|
|
121
|
+
// Stat BEFORE read, so a race lands on the conservative (stale) side.
|
|
122
122
|
const preStamp = fileStamp(filePath, CONFIG_VERSION);
|
|
123
123
|
const config = loadConfigFile(filePath, onInvalid);
|
|
124
124
|
const entries = Object.entries(config?.accounts ?? {});
|
|
125
125
|
if (entries.length === 0) {
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
// empty (BR-4), enforced by assertServerAccountsConfigured() in index.ts.
|
|
130
|
-
// The dispatch-path reload ('throw') still throws so a mid-session emptied
|
|
131
|
-
// config.json keeps the last-good registry (BR-7) instead of dropping tools.
|
|
132
|
-
if (onInvalid === 'throw') {
|
|
126
|
+
// Not fatal here: bootstrap/diagnostic CLIs must run on a fresh install;
|
|
127
|
+
// only the server refuses to boot empty (index.ts).
|
|
128
|
+
if (onInvalid === 'throw' && !opts.emptyOk) {
|
|
133
129
|
throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
|
|
134
130
|
}
|
|
135
131
|
return {
|
|
@@ -140,15 +136,14 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
140
136
|
stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
|
|
141
137
|
};
|
|
142
138
|
}
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// 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.
|
|
146
141
|
const scopeProfiles = Object.create(null);
|
|
147
142
|
scopeProfiles.base = { bundles: [] };
|
|
148
143
|
for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
|
|
149
144
|
const bundles = resolveBundleAliases(profile.bundles);
|
|
150
145
|
for (const bundle of bundles) {
|
|
151
|
-
if (!(bundle
|
|
146
|
+
if (!isKnownBundle(bundle)) {
|
|
152
147
|
const hint = closestBundle(bundle);
|
|
153
148
|
fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
154
149
|
}
|
|
@@ -167,7 +162,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
167
162
|
aliases.push(alias);
|
|
168
163
|
configs[alias] = {
|
|
169
164
|
email: entry.email,
|
|
170
|
-
...accountPaths(alias),
|
|
165
|
+
...accountPaths(alias, dir),
|
|
171
166
|
scopeProfile: entry.scopeProfile,
|
|
172
167
|
admin: adminEnv.length > 0 ? adminEnv.includes(alias) : entry.admin,
|
|
173
168
|
source: 'config',
|
|
@@ -183,9 +178,8 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
183
178
|
...def,
|
|
184
179
|
};
|
|
185
180
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* 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).
|
|
189
183
|
function resolveDefaultAccount(env, fromConfig, aliases, fail) {
|
|
190
184
|
const fromEnv = env.GOOGLE_DEFAULT_ACCOUNT?.trim();
|
|
191
185
|
const value = fromEnv || fromConfig || undefined;
|
|
@@ -196,7 +190,7 @@ function resolveDefaultAccount(env, fromConfig, aliases, fail) {
|
|
|
196
190
|
}
|
|
197
191
|
return { defaultAccount: value, defaultAccountSource: fromEnv ? 'env' : 'config' };
|
|
198
192
|
}
|
|
199
|
-
function fileStamp(filePath, version) {
|
|
193
|
+
export function fileStamp(filePath, version) {
|
|
200
194
|
try {
|
|
201
195
|
return `${version}:${fs.statSync(filePath).mtimeMs}`;
|
|
202
196
|
}
|
|
@@ -204,17 +198,14 @@ function fileStamp(filePath, version) {
|
|
|
204
198
|
return `${version}:0`;
|
|
205
199
|
}
|
|
206
200
|
}
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// 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.
|
|
210
203
|
function materializeFirstRun(aliases, configs, filePath) {
|
|
211
204
|
if (fs.existsSync(filePath))
|
|
212
205
|
return;
|
|
213
206
|
try {
|
|
214
207
|
let wrote = false;
|
|
215
|
-
//
|
|
216
|
-
// wizard/migrate writer is never clobbered (the loaded `current` is
|
|
217
|
-
// 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.
|
|
218
209
|
mutateConfigFile((current) => {
|
|
219
210
|
if (Object.keys(current.accounts ?? {}).length > 0)
|
|
220
211
|
return current;
|
|
@@ -247,7 +238,7 @@ export function invalidateAccountSet() {
|
|
|
247
238
|
return current;
|
|
248
239
|
}
|
|
249
240
|
/**
|
|
250
|
-
* Dispatch-path reload
|
|
241
|
+
* Dispatch-path reload: NEVER exits and never throws — a mid-edit,
|
|
251
242
|
* corrupt, or deleted config.json keeps the last-good set and warns once per
|
|
252
243
|
* distinct failure on stderr. failStartup semantics are boot/CLI-only.
|
|
253
244
|
*/
|
|
@@ -267,7 +258,7 @@ export function refreshAccountSetIfStale() {
|
|
|
267
258
|
}
|
|
268
259
|
}
|
|
269
260
|
}
|
|
270
|
-
/** Cross-process staleness probe
|
|
261
|
+
/** Cross-process staleness probe: one stat, compared against the stamp. */
|
|
271
262
|
export function isAccountSetStale() {
|
|
272
263
|
if (current.source !== 'file')
|
|
273
264
|
return false;
|
|
@@ -283,6 +274,8 @@ export const ACCOUNTS = current.aliases;
|
|
|
283
274
|
* enum actually accepts, and naming a runtime-added alias as valid while
|
|
284
275
|
* rejecting it would be a lie. */
|
|
285
276
|
export function unknownAliasMessage(aliases, selectors = false) {
|
|
277
|
+
if (aliases.length === 0)
|
|
278
|
+
return 'No Google account is configured yet. Add one with account_add.';
|
|
286
279
|
const all = selectors ? '; "*" for all accounts' : '';
|
|
287
280
|
const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
|
|
288
281
|
return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
|
|
@@ -291,7 +284,7 @@ export function unknownAliasMessage(aliases, selectors = false) {
|
|
|
291
284
|
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
292
285
|
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
|
293
286
|
* (module load) and take down every CLI — including `doctor`, which is meant to
|
|
294
|
-
* REPORT the empty registry
|
|
287
|
+
* REPORT the empty registry. Fall back to a plain string when there
|
|
295
288
|
* are no aliases: no alias exists to enumerate, the server refuses to boot empty
|
|
296
289
|
* anyway, and dispatch validates the account against the live set. */
|
|
297
290
|
export function accountAliasSchemaFor(aliases) {
|
|
@@ -301,8 +294,39 @@ export function accountAliasSchemaFor(aliases) {
|
|
|
301
294
|
}
|
|
302
295
|
/** Shared, load-time snapshot used by every tool's `account` field. */
|
|
303
296
|
export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
|
|
297
|
+
/** Live account argument: validates at PARSE time against the accessor's
|
|
298
|
+
* CURRENT aliases, so an alias added mid-session (account_add, or a tenant
|
|
299
|
+
* alias link) is immediately valid on already-registered tools — a baked
|
|
300
|
+
* z.enum freezes the boot-time set until restart. An empty set stays
|
|
301
|
+
* permissive (same empty-safe rule as above); dispatch still resolves the
|
|
302
|
+
* alias against the live registry. */
|
|
303
|
+
export function accountArgLive(aliases) {
|
|
304
|
+
return z.string().check(liveAccountCheck(aliases, false));
|
|
305
|
+
}
|
|
306
|
+
// The check instance survives the clones .describe() and .optional() make,
|
|
307
|
+
// so it is what marks a field as an account selector for the registry.
|
|
308
|
+
const LIVE_ACCOUNT_CHECKS = new WeakSet();
|
|
309
|
+
/** The live membership check behind accountArgLive and the fan-out field;
|
|
310
|
+
* `selectors` also admits "*". An empty set stays permissive. */
|
|
311
|
+
export function liveAccountCheck(aliases, selectors) {
|
|
312
|
+
const check = z.superRefine((value, ctx) => {
|
|
313
|
+
const current = aliases();
|
|
314
|
+
if (current.length === 0 || current.includes(value) || (selectors && value === '*'))
|
|
315
|
+
return;
|
|
316
|
+
ctx.addIssue({ code: 'custom', message: unknownAliasMessage(current, selectors) });
|
|
317
|
+
});
|
|
318
|
+
LIVE_ACCOUNT_CHECKS.add(check);
|
|
319
|
+
return check;
|
|
320
|
+
}
|
|
321
|
+
/** True for a field built on liveAccountCheck, optional or not. */
|
|
322
|
+
export function isLiveAccountField(field) {
|
|
323
|
+
let def = field?._zod?.def;
|
|
324
|
+
if (def?.type === 'optional')
|
|
325
|
+
def = def.innerType?._zod?.def;
|
|
326
|
+
return def?.checks?.some((c) => LIVE_ACCOUNT_CHECKS.has(c)) ?? false;
|
|
327
|
+
}
|
|
304
328
|
/**
|
|
305
|
-
*
|
|
329
|
+
* The stdio/http SERVER never boots with an empty registry — a fresh user
|
|
306
330
|
* bootstraps via env / `migrate-config` / `account import` / `auth` first. The
|
|
307
331
|
* bootstrap and diagnostic CLIs return before this guard, so it gates only the
|
|
308
332
|
* server path (called from index.ts after the CLI branches). */
|
package/dist/api-probe.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getClient } from './client.js';
|
|
2
|
+
import { readToken } from './token-store.js';
|
|
1
3
|
import type { ApiProbeResult } from './doctor.js';
|
|
2
4
|
export interface ApiProbeSpec {
|
|
3
5
|
service: string;
|
|
@@ -15,4 +17,7 @@ export interface ApiProbeDeps {
|
|
|
15
17
|
grantedScopes: (alias: string) => string[];
|
|
16
18
|
request: (alias: string, url: string) => Promise<void>;
|
|
17
19
|
}
|
|
20
|
+
/** Probe deps over one context's token reads and client; the module-level
|
|
21
|
+
* pair is the single owner's. */
|
|
22
|
+
export declare function apiProbeDepsFor(readTokenFn: typeof readToken, getClientFn: typeof getClient): ApiProbeDeps;
|
|
18
23
|
export declare function probeApiEnablement(alias: string, deps?: ApiProbeDeps): Promise<ApiProbeResult[]>;
|
package/dist/api-probe.js
CHANGED
|
@@ -24,21 +24,26 @@ export const API_PROBES = [
|
|
|
24
24
|
export function planProbes(granted, probes = API_PROBES) {
|
|
25
25
|
return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
/** Probe deps over one context's token reads and client; the module-level
|
|
28
|
+
* pair is the single owner's. */
|
|
29
|
+
export function apiProbeDepsFor(readTokenFn, getClientFn) {
|
|
30
|
+
return {
|
|
31
|
+
grantedScopes: (alias) => {
|
|
32
|
+
try {
|
|
33
|
+
const scope = readTokenFn(alias)?.scope;
|
|
34
|
+
return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
request: async (alias, url) => {
|
|
41
|
+
const auth = await getClientFn(alias);
|
|
42
|
+
await auth.request({ url, timeout: 10_000 });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const DEFAULT_DEPS = apiProbeDepsFor(readToken, getClient);
|
|
42
47
|
export async function probeApiEnablement(alias, deps = DEFAULT_DEPS) {
|
|
43
48
|
const results = [];
|
|
44
49
|
for (const spec of planProbes(deps.grantedScopes(alias))) {
|
package/dist/arg-normalize.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { screenArguments, unknownArgEnvelope, } from './arg-strict.js';
|
|
2
|
-
import { 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 "{".
|
|
1
|
+
import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
|
|
2
|
+
import { logSafe, sliceClean } from './trim.js';
|
|
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,8 @@ 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
|
-
log(`[args] ${tool}: undeclared ${all
|
|
105
|
+
// Key names only, escaped here and only here: the caller's names, never values, reach the log.
|
|
106
|
+
log(`[args] ${tool}: undeclared ${logSafe(echoNames(all), 600)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
|
|
120
107
|
try {
|
|
121
108
|
// Only ever a DECLARED key or the literal placeholder, so the metrics
|
|
122
109
|
// closed-vocabulary rule holds: the caller's key is never persisted.
|
|
@@ -132,7 +119,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
|
|
|
132
119
|
const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
|
|
133
120
|
? []
|
|
134
121
|
: (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
|
|
135
|
-
const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
|
|
122
|
+
const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? echoName(account) : undefined, siblings);
|
|
136
123
|
return {
|
|
137
124
|
action: 'reject',
|
|
138
125
|
response: {
|
|
@@ -166,13 +153,10 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
|
|
|
166
153
|
if (outcome.action === 'forward')
|
|
167
154
|
return handler(outcome.msg, extra);
|
|
168
155
|
// Last-resort fallback: dispatch as before rather than hang the
|
|
169
|
-
// caller
|
|
170
|
-
// `send` is async, so a rejected promise needs catching too: a
|
|
171
|
-
// 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.
|
|
172
157
|
const fallback = () => handler(normalized, extra);
|
|
173
158
|
try {
|
|
174
|
-
//
|
|
175
|
-
// frame and clears its pending id.
|
|
159
|
+
// On the INNER transport so the metrics tap still sees the frame and clears its pending id.
|
|
176
160
|
void Promise.resolve(transport.send(outcome.response)).catch(fallback);
|
|
177
161
|
}
|
|
178
162
|
catch {
|
|
@@ -194,10 +178,8 @@ export function withArgNormalization(transport, shapeFor, log, onRename, strict)
|
|
|
194
178
|
if (transport.setProtocolVersion) {
|
|
195
179
|
wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
|
|
196
180
|
}
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
// eats a member the SDK calls is a bug waiting for the first caller that
|
|
200
|
-
// 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.
|
|
201
183
|
if (transport.setSupportedProtocolVersions) {
|
|
202
184
|
wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
|
|
203
185
|
}
|
|
@@ -229,9 +211,8 @@ export function outputFailureEnvelope(tool, text, account) {
|
|
|
229
211
|
}
|
|
230
212
|
export function rewriteValidationFrame(message, pending, opts) {
|
|
231
213
|
const m = message;
|
|
232
|
-
// Responses only
|
|
233
|
-
//
|
|
234
|
-
// 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.
|
|
235
216
|
if (m.id === undefined || m.method !== undefined)
|
|
236
217
|
return message;
|
|
237
218
|
const call = pending.get(m.id);
|
package/dist/arg-strict.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
|
|
|
14
14
|
* tools: no declared key starts with `_` or contains `/`, so neither rule can
|
|
15
15
|
* shadow a real parameter. */
|
|
16
16
|
export declare function isExemptKey(key: string): boolean;
|
|
17
|
+
/** A call can carry thousands of unknown keys, or one of megabytes: only the
|
|
18
|
+
* first few earn a did-you-mean, and the log line and the envelope echo a
|
|
19
|
+
* bounded prefix of each name. The reject decision itself is never capped. */
|
|
20
|
+
export declare const MAX_SUGGESTED_KEYS = 8;
|
|
21
|
+
export declare function echoName(name: string): string;
|
|
22
|
+
/** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
|
|
23
|
+
export declare function echoNames(names: readonly string[], quote?: boolean): string;
|
|
17
24
|
/**
|
|
18
25
|
* Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
|
|
19
26
|
* 0 same key modulo case and separators
|