mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.10
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 +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/api-probe.d.ts +18 -0
- package/dist/api-probe.js +65 -0
- package/dist/arg-normalize.d.ts +13 -0
- package/dist/arg-normalize.js +80 -0
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +98 -0
- package/dist/doctor.js +467 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +61 -0
- package/dist/http-transport.js +270 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +202 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +30 -1
- package/dist/registry.js +141 -6
- package/dist/scope-catalog.d.ts +20 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +113 -6
- package/dist/tools/_local-files.d.ts +3 -0
- package/dist/tools/_local-files.js +29 -0
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +5 -1
- package/dist/tools/drive.js +134 -40
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +486 -102
- package/dist/tools/google-api.d.ts +4 -1
- package/dist/tools/google-api.js +34 -6
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +41 -22
package/README.md
CHANGED
|
@@ -4,49 +4,54 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
7
|
-
- 🧰 **Exhaustive** —
|
|
7
|
+
- 🧰 **Exhaustive** — 874 tools across 28 services + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
|
|
8
8
|
- 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
|
|
9
9
|
- 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
|
|
10
|
+
- 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI) → [remote setup](./docs/http-setup.md)
|
|
11
|
+
- ✉️ **Built for real work** — send and read email in Markdown with attachments and one-call replies, an interactive setup wizard with a `doctor` self-check, and per-account scope profiles → [features tour](./docs/features.md)
|
|
10
12
|
|
|
11
13
|
## Quick setup
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
New to all this? It's written for someone who just installed Claude Code and has never made an API key. Copy-paste each step; it says what you'll see. (Already technical? The [Configuration reference](./docs/configuration.md) is the terse version.)
|
|
14
16
|
|
|
15
|
-
1. **Install [Node.js](https://nodejs.org)
|
|
17
|
+
1. **Install it.** Get [Node.js](https://nodejs.org) (the green "LTS" button, version 22 or newer), then run:
|
|
16
18
|
|
|
17
19
|
```bash
|
|
18
20
|
npm install -g mcp-google-multi
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
2. **
|
|
23
|
+
2. **Make your Google key** (the one manual part, a few minutes, because Google has no way to script it). Follow the step-by-step [Google Cloud setup](./docs/google-cloud-setup.md), or just ask Claude Code: *"walk me through creating a Google OAuth Desktop client for mcp-google-multi."* You finish with two values, a **Client ID** and a **Client Secret**. It's free and private to you.
|
|
22
24
|
|
|
23
|
-
3. **
|
|
25
|
+
3. **Put them in a file.** In the folder you'll run from, make a file named `.env` and paste this, filling in your values:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
28
|
GOOGLE_CLIENT_ID=paste-your-client-id
|
|
27
29
|
GOOGLE_CLIENT_SECRET=paste-your-client-secret
|
|
28
|
-
#
|
|
29
|
-
GOOGLE_ACCOUNTS=
|
|
30
|
-
# encryption key for stored tokens — generate one with: openssl rand -base64 32
|
|
31
|
-
MASTER_KEY=paste-the-generated-key
|
|
30
|
+
# any short nickname, then your Gmail address:
|
|
31
|
+
GOOGLE_ACCOUNTS=me:you@gmail.com
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
No encryption key to make: the server generates and stores one for you.
|
|
35
|
+
|
|
36
|
+
4. **Sign in.** A browser opens; pick your account and click Allow:
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
|
-
mcp-google-multi auth --account
|
|
38
|
-
mcp-google-multi auth --account personal
|
|
39
|
+
mcp-google-multi auth --account me
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
5. **
|
|
42
|
+
5. **Add it to Claude Code, then restart Claude Code:**
|
|
42
43
|
|
|
43
44
|
```bash
|
|
44
45
|
claude mcp add google-multi -s user -- npx -y mcp-google-multi
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
**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."*
|
|
49
|
+
|
|
50
|
+
*Got more than one Google account?* Add them together, like `GOOGLE_ACCOUNTS=me:you@gmail.com,work:you@company.com`, and run step 4 once per nickname.
|
|
51
|
+
|
|
52
|
+
*On a server or from claude.ai?* Advanced path: [Remote / HTTP setup](./docs/http-setup.md). *Coming from v5?* [v6 migration guide](./MIGRATION-v6.md).
|
|
48
53
|
|
|
49
|
-
**Go deeper:** [Configuration reference](./docs/configuration.md) · [What's covered](./COVERAGE.md) · [Features tour](./docs/features.md) · [Secrets in a vault](./docs/secrets.md) · [
|
|
54
|
+
**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) · [Migrating to v6](./MIGRATION-v6.md) · [Security policy](./SECURITY.md) · [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
|
|
50
55
|
|
|
51
56
|
## Maintainer & credits
|
|
52
57
|
|
|
@@ -54,7 +59,7 @@ Built and maintained by **Abdelbaki Berkati** — [berkati.xyz](https://berkati.
|
|
|
54
59
|
|
|
55
60
|
Development is **funded by [IdeaCrafters](https://ideacrafters.com)** ([@IdeaCraftersHQ](https://github.com/IdeaCraftersHQ)) — the studio that pays for this OSS to exist.
|
|
56
61
|
|
|
57
|
-
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)). **
|
|
62
|
+
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.
|
|
58
63
|
|
|
59
64
|
## License
|
|
60
65
|
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ScopeProfile } from './scope-catalog.js';
|
|
3
|
+
export declare function getTokenDir(): string;
|
|
1
4
|
export interface AccountConfig {
|
|
2
5
|
email: string;
|
|
3
6
|
tokenPath: string;
|
|
4
7
|
encPath: string;
|
|
8
|
+
scopeProfile?: string;
|
|
9
|
+
admin?: boolean;
|
|
10
|
+
source: 'config' | 'env';
|
|
5
11
|
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
export interface AccountSet {
|
|
13
|
+
aliases: string[];
|
|
14
|
+
configs: Record<string, AccountConfig>;
|
|
15
|
+
scopeProfiles: Record<string, ScopeProfile>;
|
|
16
|
+
source: 'env' | 'file' | 'merged';
|
|
17
|
+
stamp: string;
|
|
18
|
+
defaultAccount?: string;
|
|
19
|
+
defaultAccountSource?: 'env' | 'config';
|
|
20
|
+
}
|
|
21
|
+
export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw'): AccountSet;
|
|
22
|
+
/** Live accessor: dispatch-time readers use this, never a captured snapshot. */
|
|
23
|
+
export declare function getAccountSet(): AccountSet;
|
|
24
|
+
/** Re-resolve after a config.json mutation and swap the live set. */
|
|
25
|
+
export declare function invalidateAccountSet(): AccountSet;
|
|
26
|
+
export declare function refreshAccountSetIfStale(): void;
|
|
27
|
+
/** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
|
|
28
|
+
export declare function isAccountSetStale(): boolean;
|
|
29
|
+
/** Account aliases (possibly empty on a fresh install).
|
|
30
|
+
* Snapshot from the initial load; enums widen only when the registry is
|
|
31
|
+
* rebuilt after a mutation (account_add, later slice). */
|
|
32
|
+
export declare const ACCOUNTS: string[];
|
|
33
|
+
/**
|
|
34
|
+
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
35
|
+
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
|
36
|
+
* (module load) and take down every CLI — including `doctor`, which is meant to
|
|
37
|
+
* REPORT the empty registry (gap #23). Fall back to a plain string when there
|
|
38
|
+
* are no aliases: no alias exists to enumerate, the server refuses to boot empty
|
|
39
|
+
* anyway, and dispatch validates the account against the live set. */
|
|
40
|
+
export declare function accountAliasSchemaFor(aliases: readonly string[]): z.ZodType<string>;
|
|
41
|
+
/** Shared, load-time snapshot used by every tool's `account` field. */
|
|
42
|
+
export declare const accountAliasSchema: z.ZodType<string>;
|
|
43
|
+
/**
|
|
44
|
+
* BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
|
|
45
|
+
* bootstraps via env / `migrate-config` / `account import` / `auth` first. The
|
|
46
|
+
* bootstrap and diagnostic CLIs return before this guard, so it gates only the
|
|
47
|
+
* server path (called from index.ts after the CLI branches). */
|
|
48
|
+
export declare function assertServerAccountsConfigured(): void;
|
|
49
|
+
/** Valid account alias (string union isn't static, so tools use accountAliasSchema) */
|
|
11
50
|
export type Account = string;
|
package/dist/accounts.js
CHANGED
|
@@ -1,23 +1,28 @@
|
|
|
1
|
-
import dotenv from 'dotenv';
|
|
2
|
-
import { fileURLToPath } from 'node:url';
|
|
3
1
|
import path from 'node:path';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const defaultTokenDir = path.join(
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { loadEnvFiles } from './env-load.js';
|
|
5
|
+
import { CONFIG_VERSION, configDir, configFilePath, failStartup, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
6
|
+
import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
7
|
+
const envLoad = loadEnvFiles();
|
|
8
|
+
const defaultTokenDir = path.join(configDir(), 'tokens');
|
|
11
9
|
const tokenDir = process.env.TOKEN_STORE_PATH
|
|
12
10
|
? path.resolve(process.env.TOKEN_STORE_PATH)
|
|
13
11
|
: defaultTokenDir;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
export function getTokenDir() {
|
|
13
|
+
return tokenDir;
|
|
14
|
+
}
|
|
15
|
+
function parseCsv(value) {
|
|
16
|
+
return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
function accountPaths(alias) {
|
|
19
|
+
return {
|
|
20
|
+
tokenPath: path.join(tokenDir, alias, 'token.json'),
|
|
21
|
+
encPath: path.join(tokenDir, `${alias}.enc`),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** v5 env parser, guards verbatim. Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
|
|
25
|
+
function parseEnvAccounts(raw, adminAliases) {
|
|
21
26
|
const configs = {};
|
|
22
27
|
const aliases = [];
|
|
23
28
|
for (const entry of raw.split(',')) {
|
|
@@ -35,7 +40,7 @@ function parseAccounts() {
|
|
|
35
40
|
}
|
|
36
41
|
// Restrict alias to a safe charset so it can't escape `tokenDir` via path traversal
|
|
37
42
|
// (e.g. "../../etc/passwd:foo@bar.com" in .env).
|
|
38
|
-
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
|
43
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(alias) || ['__proto__', 'constructor', 'prototype'].includes(alias)) {
|
|
39
44
|
throw new Error(`Invalid alias "${alias}". Allowed characters: letters, digits, underscore, hyphen.`);
|
|
40
45
|
}
|
|
41
46
|
if (aliases.includes(alias)) {
|
|
@@ -44,17 +49,254 @@ function parseAccounts() {
|
|
|
44
49
|
aliases.push(alias);
|
|
45
50
|
configs[alias] = {
|
|
46
51
|
email,
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
...accountPaths(alias),
|
|
53
|
+
admin: adminAliases.includes(alias) || undefined,
|
|
54
|
+
source: 'env',
|
|
49
55
|
};
|
|
50
56
|
}
|
|
51
57
|
if (aliases.length === 0) {
|
|
52
58
|
throw new Error('GOOGLE_ACCOUNTS must define at least one account.');
|
|
53
59
|
}
|
|
54
|
-
return { aliases
|
|
60
|
+
return { aliases, configs };
|
|
61
|
+
}
|
|
62
|
+
function noAccountsMessage() {
|
|
63
|
+
const envHint = envLoad.loaded.length === 0
|
|
64
|
+
? ` No readable .env file was found (searched: ${envLoad.searched.join(', ')}).`
|
|
65
|
+
: '';
|
|
66
|
+
return (`no accounts configured. Add them to ${configFilePath()} (run: mcp-google-multi migrate-config), ` +
|
|
67
|
+
`or set GOOGLE_ACCOUNTS=work:user@company.com,personal:user@gmail.com in the environment.${envHint}`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Registry resolution (BR-2): a non-empty GOOGLE_ACCOUNTS env takes the WHOLE
|
|
71
|
+
* registry from env (12-factor override, never merged key-by-key); otherwise
|
|
72
|
+
* the registry comes from config.json. GOOGLE_ADMIN_ACCOUNTS, when NON-EMPTY,
|
|
73
|
+
* overrides per-account admin flags from the file (env var > config field);
|
|
74
|
+
* empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
|
|
75
|
+
*/
|
|
76
|
+
let warnedLegacy = false;
|
|
77
|
+
export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit') {
|
|
78
|
+
const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
|
|
79
|
+
const rawEnv = env.GOOGLE_ACCOUNTS;
|
|
80
|
+
const fail = (slug, message) => {
|
|
81
|
+
if (onInvalid === 'throw')
|
|
82
|
+
throw new Error(`${slug}: ${message}`);
|
|
83
|
+
failStartup(slug, message);
|
|
84
|
+
};
|
|
85
|
+
// Validate the legacy global override AT BOOT, whatever GOOGLE_TOOLSETS
|
|
86
|
+
// selects — otherwise a typo only surfaces at dispatch time inside
|
|
87
|
+
// account_list instead of the promised startup error (BR3).
|
|
88
|
+
const legacyNames = parseCsv(env.GOOGLE_OPTIONAL_SCOPES);
|
|
89
|
+
if (legacyNames.length > 0) {
|
|
90
|
+
for (const bundle of resolveBundleAliases(legacyNames)) {
|
|
91
|
+
if (bundle === 'admin') {
|
|
92
|
+
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
|
+
}
|
|
94
|
+
if (!(bundle in BUNDLE_CATALOG)) {
|
|
95
|
+
const hint = closestBundle(bundle);
|
|
96
|
+
fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!warnedLegacy) {
|
|
100
|
+
warnedLegacy = true;
|
|
101
|
+
process.stderr.write('E_LEGACY_GLOBAL_SCOPES: GOOGLE_OPTIONAL_SCOPES applies one global scope set to every account; migrate to per-account scopeProfiles (mcp-google-multi migrate-config).\n');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (rawEnv && rawEnv.trim() !== '') {
|
|
105
|
+
const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv);
|
|
106
|
+
materializeFirstRun(aliases, configs, filePath);
|
|
107
|
+
const def = resolveDefaultAccount(env, null, aliases, fail);
|
|
108
|
+
return {
|
|
109
|
+
aliases,
|
|
110
|
+
configs,
|
|
111
|
+
// Env-sourced accounts cannot reference file profiles; the legacy
|
|
112
|
+
// GOOGLE_OPTIONAL_SCOPES override is applied live in auth.ts.
|
|
113
|
+
scopeProfiles: { base: { bundles: [] } },
|
|
114
|
+
source: 'env',
|
|
115
|
+
stamp: 'env:0',
|
|
116
|
+
...def,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Stat BEFORE read: a cross-process write landing between the two makes the
|
|
120
|
+
// stamp conservative (flags stale again next dispatch) instead of pinning
|
|
121
|
+
// stale content behind a fresh stamp.
|
|
122
|
+
const preStamp = fileStamp(filePath, CONFIG_VERSION);
|
|
123
|
+
const config = loadConfigFile(filePath, onInvalid);
|
|
124
|
+
const entries = Object.entries(config?.accounts ?? {});
|
|
125
|
+
if (entries.length === 0) {
|
|
126
|
+
// Gap #23: an empty registry is NOT fatal at resolve time — the bootstrap and
|
|
127
|
+
// diagnostic CLIs (doctor/reset/account import/migrate-config/config check)
|
|
128
|
+
// must run on a fresh install. Only the stdio/http SERVER refuses to boot
|
|
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') {
|
|
133
|
+
throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
aliases: [],
|
|
137
|
+
configs: {},
|
|
138
|
+
scopeProfiles: { base: { bundles: [] } },
|
|
139
|
+
source: 'file',
|
|
140
|
+
stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// BR3: unknown bundle names fail loudly (a mis-scoped token is worse than a
|
|
144
|
+
// clear error); v5 silently filtered them. Null prototype: profile names are
|
|
145
|
+
// user input and must never collide with Object.prototype members.
|
|
146
|
+
const scopeProfiles = Object.create(null);
|
|
147
|
+
scopeProfiles.base = { bundles: [] };
|
|
148
|
+
for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
|
|
149
|
+
const bundles = resolveBundleAliases(profile.bundles);
|
|
150
|
+
for (const bundle of bundles) {
|
|
151
|
+
if (!(bundle in BUNDLE_CATALOG)) {
|
|
152
|
+
const hint = closestBundle(bundle);
|
|
153
|
+
fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (profile.includesBase === false && bundles.length === 0 && profile.admin !== true) {
|
|
157
|
+
fail('E_CONFIG_INVALID', `scope profile "${name}" resolves to zero scopes (includesBase: false with no bundles); Google rejects an empty consent request.`);
|
|
158
|
+
}
|
|
159
|
+
scopeProfiles[name] = { ...profile, bundles };
|
|
160
|
+
}
|
|
161
|
+
const configs = {};
|
|
162
|
+
const aliases = [];
|
|
163
|
+
for (const [alias, entry] of entries) {
|
|
164
|
+
if (entry.scopeProfile && !Object.hasOwn(scopeProfiles, entry.scopeProfile)) {
|
|
165
|
+
fail('E_CONFIG_INVALID', `account "${alias}" references scope profile "${entry.scopeProfile}", which is not defined in scopeProfiles.`);
|
|
166
|
+
}
|
|
167
|
+
aliases.push(alias);
|
|
168
|
+
configs[alias] = {
|
|
169
|
+
email: entry.email,
|
|
170
|
+
...accountPaths(alias),
|
|
171
|
+
scopeProfile: entry.scopeProfile,
|
|
172
|
+
admin: adminEnv.length > 0 ? adminEnv.includes(alias) : entry.admin,
|
|
173
|
+
source: 'config',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const def = resolveDefaultAccount(env, config?.defaultAccount ?? null, aliases, fail);
|
|
177
|
+
return {
|
|
178
|
+
aliases,
|
|
179
|
+
configs,
|
|
180
|
+
scopeProfiles,
|
|
181
|
+
source: 'file',
|
|
182
|
+
stamp: `${config?.version ?? CONFIG_VERSION}:${preStamp.split(':')[1]}`,
|
|
183
|
+
...def,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** A2: env GOOGLE_DEFAULT_ACCOUNT > config.defaultAccount > unset. A configured
|
|
187
|
+
* default naming an unknown alias refuses to start (E_DEFAULT_ACCOUNT_UNKNOWN —
|
|
188
|
+
* deliberately NOT E_CONFIG_INVALID: the config is schema-valid). */
|
|
189
|
+
function resolveDefaultAccount(env, fromConfig, aliases, fail) {
|
|
190
|
+
const fromEnv = env.GOOGLE_DEFAULT_ACCOUNT?.trim();
|
|
191
|
+
const value = fromEnv || fromConfig || undefined;
|
|
192
|
+
if (!value)
|
|
193
|
+
return {};
|
|
194
|
+
if (!aliases.includes(value)) {
|
|
195
|
+
fail('E_DEFAULT_ACCOUNT_UNKNOWN', `default account "${value}" (from ${fromEnv ? 'GOOGLE_DEFAULT_ACCOUNT' : 'config.json defaultAccount'}) is not a configured alias. Valid: ${aliases.join(', ')}.`);
|
|
196
|
+
}
|
|
197
|
+
return { defaultAccount: value, defaultAccountSource: fromEnv ? 'env' : 'config' };
|
|
198
|
+
}
|
|
199
|
+
function fileStamp(filePath, version) {
|
|
200
|
+
try {
|
|
201
|
+
return `${version}:${fs.statSync(filePath).mtimeMs}`;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return `${version}:0`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// First-run shim (BC6): env is set and no config.json exists yet — materialize
|
|
208
|
+
// the file so the wizard has something to edit. Env still wins this session;
|
|
209
|
+
// a write failure must never block boot (warn on stderr and continue).
|
|
210
|
+
function materializeFirstRun(aliases, configs, filePath) {
|
|
211
|
+
if (fs.existsSync(filePath))
|
|
212
|
+
return;
|
|
213
|
+
try {
|
|
214
|
+
let wrote = false;
|
|
215
|
+
// mutateConfigFile = lock + re-check + atomic write, so a concurrent
|
|
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).
|
|
218
|
+
mutateConfigFile((current) => {
|
|
219
|
+
if (Object.keys(current.accounts ?? {}).length > 0)
|
|
220
|
+
return current;
|
|
221
|
+
const accounts = {};
|
|
222
|
+
for (const alias of aliases) {
|
|
223
|
+
accounts[alias] = {
|
|
224
|
+
email: configs[alias].email,
|
|
225
|
+
...(configs[alias].admin ? { admin: true } : {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
wrote = true;
|
|
229
|
+
return { ...current, version: current.version || CONFIG_VERSION, accounts };
|
|
230
|
+
}, filePath);
|
|
231
|
+
if (wrote) {
|
|
232
|
+
process.stderr.write(`Materialized ${filePath} from GOOGLE_ACCOUNTS (env still overrides while set).\n`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
process.stderr.write(`Could not materialize ${filePath}: ${e.message}\n`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
let current = resolveAccounts();
|
|
240
|
+
/** Live accessor: dispatch-time readers use this, never a captured snapshot. */
|
|
241
|
+
export function getAccountSet() {
|
|
242
|
+
return current;
|
|
243
|
+
}
|
|
244
|
+
/** Re-resolve after a config.json mutation and swap the live set. */
|
|
245
|
+
export function invalidateAccountSet() {
|
|
246
|
+
current = resolveAccounts();
|
|
247
|
+
return current;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Dispatch-path reload (BR-7): NEVER exits and never throws — a mid-edit,
|
|
251
|
+
* corrupt, or deleted config.json keeps the last-good set and warns once per
|
|
252
|
+
* distinct failure on stderr. failStartup semantics are boot/CLI-only.
|
|
253
|
+
*/
|
|
254
|
+
let lastReloadWarning = '';
|
|
255
|
+
export function refreshAccountSetIfStale() {
|
|
256
|
+
if (!isAccountSetStale())
|
|
257
|
+
return;
|
|
258
|
+
try {
|
|
259
|
+
current = resolveAccounts(process.env, configFilePath(), 'throw');
|
|
260
|
+
lastReloadWarning = '';
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
const msg = e.message;
|
|
264
|
+
if (msg !== lastReloadWarning) {
|
|
265
|
+
process.stderr.write(`config.json reload skipped (keeping last-good registry): ${msg}\n`);
|
|
266
|
+
lastReloadWarning = msg;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Cross-process staleness probe (BR-7): one stat, compared against the stamp. */
|
|
271
|
+
export function isAccountSetStale() {
|
|
272
|
+
if (current.source !== 'file')
|
|
273
|
+
return false;
|
|
274
|
+
const [version] = current.stamp.split(':');
|
|
275
|
+
return current.stamp !== fileStamp(configFilePath(), Number(version));
|
|
276
|
+
}
|
|
277
|
+
/** Account aliases (possibly empty on a fresh install).
|
|
278
|
+
* Snapshot from the initial load; enums widen only when the registry is
|
|
279
|
+
* rebuilt after a mutation (account_add, later slice). */
|
|
280
|
+
export const ACCOUNTS = current.aliases;
|
|
281
|
+
/**
|
|
282
|
+
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
283
|
+
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
|
284
|
+
* (module load) and take down every CLI — including `doctor`, which is meant to
|
|
285
|
+
* REPORT the empty registry (gap #23). Fall back to a plain string when there
|
|
286
|
+
* are no aliases: no alias exists to enumerate, the server refuses to boot empty
|
|
287
|
+
* anyway, and dispatch validates the account against the live set. */
|
|
288
|
+
export function accountAliasSchemaFor(aliases) {
|
|
289
|
+
return aliases.length > 0 ? z.enum(aliases) : z.string();
|
|
290
|
+
}
|
|
291
|
+
/** Shared, load-time snapshot used by every tool's `account` field. */
|
|
292
|
+
export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
|
|
293
|
+
/**
|
|
294
|
+
* BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
|
|
295
|
+
* bootstraps via env / `migrate-config` / `account import` / `auth` first. The
|
|
296
|
+
* bootstrap and diagnostic CLIs return before this guard, so it gates only the
|
|
297
|
+
* server path (called from index.ts after the CLI branches). */
|
|
298
|
+
export function assertServerAccountsConfigured() {
|
|
299
|
+
if (current.aliases.length === 0) {
|
|
300
|
+
failStartup('E_NO_ACCOUNTS_CONFIGURED', noAccountsMessage());
|
|
301
|
+
}
|
|
55
302
|
}
|
|
56
|
-
const parsed = parseAccounts();
|
|
57
|
-
/** Tuple of account aliases (at least one) — usable with z.enum() */
|
|
58
|
-
export const ACCOUNTS = parsed.aliases;
|
|
59
|
-
/** Map of alias → { email, tokenPath } */
|
|
60
|
-
export const ACCOUNT_CONFIG = parsed.configs;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ApiProbeResult } from './doctor.js';
|
|
2
|
+
export interface ApiProbeSpec {
|
|
3
|
+
service: string;
|
|
4
|
+
/** console library id for the enable deep-link, e.g. "calendar-json". */
|
|
5
|
+
api: string;
|
|
6
|
+
url: string;
|
|
7
|
+
scopePrefixes: string[];
|
|
8
|
+
/** id-required APIs have no no-arg read; a 404 on a nonexistent id still
|
|
9
|
+
* proves the API is enabled (accessNotConfigured wins before routing). */
|
|
10
|
+
notFoundMeansEnabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare const API_PROBES: ApiProbeSpec[];
|
|
13
|
+
export declare function planProbes(granted: string[], probes?: ApiProbeSpec[]): ApiProbeSpec[];
|
|
14
|
+
export interface ApiProbeDeps {
|
|
15
|
+
grantedScopes: (alias: string) => string[];
|
|
16
|
+
request: (alias: string, url: string) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export declare function probeApiEnablement(alias: string, deps?: ApiProbeDeps): Promise<ApiProbeResult[]>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getClient } from './client.js';
|
|
2
|
+
import { readToken } from './token-store.js';
|
|
3
|
+
import { mapGoogleError } from './tools/_errors.js';
|
|
4
|
+
const P = 'https://www.googleapis.com/auth/';
|
|
5
|
+
const BOGUS_ID = 'mcp-google-multi-probe-nonexistent';
|
|
6
|
+
export const API_PROBES = [
|
|
7
|
+
{ service: 'gmail', api: 'gmail', url: 'https://gmail.googleapis.com/gmail/v1/users/me/profile', scopePrefixes: [`${P}gmail.`] },
|
|
8
|
+
{ service: 'drive', api: 'drive', url: 'https://www.googleapis.com/drive/v3/about?fields=user', scopePrefixes: [`${P}drive`] },
|
|
9
|
+
{ service: 'calendar', api: 'calendar-json', url: 'https://www.googleapis.com/calendar/v3/users/me/calendarList?maxResults=1', scopePrefixes: [`${P}calendar`] },
|
|
10
|
+
// people/me needs profile scopes, not contacts; connections is the read the
|
|
11
|
+
// contacts grant actually authorizes.
|
|
12
|
+
{ service: 'contacts', api: 'people', url: 'https://people.googleapis.com/v1/people/me/connections?personFields=names&pageSize=1', scopePrefixes: [`${P}contacts`] },
|
|
13
|
+
{ service: 'sheets', api: 'sheets', url: `https://sheets.googleapis.com/v4/spreadsheets/${BOGUS_ID}`, scopePrefixes: [`${P}spreadsheets`], notFoundMeansEnabled: true },
|
|
14
|
+
{ service: 'docs', api: 'docs', url: `https://docs.googleapis.com/v1/documents/${BOGUS_ID}`, scopePrefixes: [`${P}documents`], notFoundMeansEnabled: true },
|
|
15
|
+
{ service: 'searchconsole', api: 'searchconsole', url: 'https://www.googleapis.com/webmasters/v3/sites', scopePrefixes: [`${P}webmasters`] },
|
|
16
|
+
{ service: 'tasks', api: 'tasks', url: 'https://tasks.googleapis.com/tasks/v1/users/@me/lists?maxResults=1', scopePrefixes: [`${P}tasks`] },
|
|
17
|
+
{ service: 'chat', api: 'chat', url: 'https://chat.googleapis.com/v1/spaces?pageSize=1', scopePrefixes: [`${P}chat.`] },
|
|
18
|
+
{ service: 'meet', api: 'meet', url: 'https://meet.googleapis.com/v2/conferenceRecords?pageSize=1', scopePrefixes: [`${P}meetings.`] },
|
|
19
|
+
{ service: 'forms', api: 'forms', url: `https://forms.googleapis.com/v1/forms/${BOGUS_ID}`, scopePrefixes: [`${P}forms.`], notFoundMeansEnabled: true },
|
|
20
|
+
];
|
|
21
|
+
export function planProbes(granted, probes = API_PROBES) {
|
|
22
|
+
return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
|
|
23
|
+
}
|
|
24
|
+
const DEFAULT_DEPS = {
|
|
25
|
+
grantedScopes: (alias) => {
|
|
26
|
+
try {
|
|
27
|
+
const scope = readToken(alias)?.scope;
|
|
28
|
+
return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
request: async (alias, url) => {
|
|
35
|
+
const auth = await getClient(alias);
|
|
36
|
+
await auth.request({ url, timeout: 10_000 });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export async function probeApiEnablement(alias, deps = DEFAULT_DEPS) {
|
|
40
|
+
const results = [];
|
|
41
|
+
for (const spec of planProbes(deps.grantedScopes(alias))) {
|
|
42
|
+
try {
|
|
43
|
+
await deps.request(alias, spec.url);
|
|
44
|
+
results.push({ service: spec.service, api: spec.api, ok: true });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
const envelope = mapGoogleError(error, alias);
|
|
48
|
+
if (envelope.error === 'network_error') {
|
|
49
|
+
// One connect failure means they will all fail: abort so section 6
|
|
50
|
+
// reports a single WARN "Probe could not complete" with the code.
|
|
51
|
+
throw new Error(envelope.message, { cause: error });
|
|
52
|
+
}
|
|
53
|
+
if (envelope.error === 'api_not_enabled') {
|
|
54
|
+
results.push({ service: spec.service, api: spec.api, ok: false, notEnabled: true, message: envelope.message });
|
|
55
|
+
}
|
|
56
|
+
else if (spec.notFoundMeansEnabled && envelope.error === 'not_found') {
|
|
57
|
+
results.push({ service: spec.service, api: spec.api, ok: true });
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
results.push({ service: spec.service, api: spec.api, ok: false, message: envelope.error });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
2
|
+
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
4
|
+
export declare function normalizeCallArguments(shapeKeys: ReadonlySet<string>, args: Record<string, unknown>): {
|
|
5
|
+
args: Record<string, unknown>;
|
|
6
|
+
renamed: [string, string][];
|
|
7
|
+
};
|
|
8
|
+
export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ReadonlySet<string> | undefined, log?: (line: string) => void): JSONRPCMessage;
|
|
9
|
+
/** Wrap a server-side transport so tools/call argument keys are normalized
|
|
10
|
+
* before the SDK validates them. The Protocol assigns `onmessage` during
|
|
11
|
+
* connect(); the interceptor lives in that setter, so the wrapper works
|
|
12
|
+
* identically for stdio and (per-request, stateless) HTTP transports. */
|
|
13
|
+
export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ReadonlySet<string> | undefined, log?: (line: string) => void): Transport;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Wire-level tools/call argument normalization. Clients (LLMs) recurringly
|
|
2
|
+
// snake_case a camelCase parameter (thread_id for threadId) and burn a retry
|
|
3
|
+
// on the -32602. A schema-level fix is off the table: SDK 1.x advertises an
|
|
4
|
+
// EMPTY input schema for any non-object wrapper (pipe/preprocess), so the
|
|
5
|
+
// only seam that keeps tools/list intact is the JSON-RPC message itself —
|
|
6
|
+
// which is versioned MCP spec, stabler than any SDK internal. The rename is
|
|
7
|
+
// provably lossless: it fires only when the sent key is NOT in the tool's
|
|
8
|
+
// schema, its camelCase twin IS, and that twin was not also sent.
|
|
9
|
+
export function argNormalizationEnabled(env = process.env) {
|
|
10
|
+
return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
|
|
11
|
+
}
|
|
12
|
+
const snakeToCamel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
|
|
13
|
+
export function normalizeCallArguments(shapeKeys, args) {
|
|
14
|
+
const renamed = [];
|
|
15
|
+
let out;
|
|
16
|
+
for (const key of Object.keys(args)) {
|
|
17
|
+
if (shapeKeys.has(key) || !key.includes('_'))
|
|
18
|
+
continue;
|
|
19
|
+
const camel = snakeToCamel(key);
|
|
20
|
+
if (camel !== key && shapeKeys.has(camel) && !(camel in args)) {
|
|
21
|
+
out ??= { ...args };
|
|
22
|
+
out[camel] = out[key];
|
|
23
|
+
delete out[key];
|
|
24
|
+
renamed.push([key, camel]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { args: out ?? args, renamed };
|
|
28
|
+
}
|
|
29
|
+
export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`)) {
|
|
30
|
+
const m = msg;
|
|
31
|
+
if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
|
|
32
|
+
return msg;
|
|
33
|
+
const args = m.params.arguments;
|
|
34
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
35
|
+
return msg;
|
|
36
|
+
const shape = shapeFor(m.params.name);
|
|
37
|
+
if (!shape)
|
|
38
|
+
return msg;
|
|
39
|
+
const { args: normalized, renamed } = normalizeCallArguments(shape, args);
|
|
40
|
+
if (renamed.length === 0)
|
|
41
|
+
return msg;
|
|
42
|
+
// Key names only — argument VALUES never reach the log.
|
|
43
|
+
log(`[args] ${m.params.name}: ${renamed.map(([f, t]) => `${f} -> ${t}`).join(', ')}`);
|
|
44
|
+
return {
|
|
45
|
+
...msg,
|
|
46
|
+
params: { ...m.params, arguments: normalized },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Wrap a server-side transport so tools/call argument keys are normalized
|
|
50
|
+
* before the SDK validates them. The Protocol assigns `onmessage` during
|
|
51
|
+
* connect(); the interceptor lives in that setter, so the wrapper works
|
|
52
|
+
* identically for stdio and (per-request, stateless) HTTP transports. */
|
|
53
|
+
export function withArgNormalization(transport, shapeFor, log) {
|
|
54
|
+
const wrapper = {
|
|
55
|
+
start: () => transport.start(),
|
|
56
|
+
send: (message, options) => transport.send(message, options),
|
|
57
|
+
close: () => transport.close(),
|
|
58
|
+
};
|
|
59
|
+
Object.defineProperty(wrapper, 'onmessage', {
|
|
60
|
+
get: () => transport.onmessage,
|
|
61
|
+
set: (handler) => {
|
|
62
|
+
transport.onmessage = handler
|
|
63
|
+
? (message, extra) => handler(normalizeMessage(message, shapeFor, log), extra)
|
|
64
|
+
: undefined;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
for (const prop of ['onclose', 'onerror']) {
|
|
68
|
+
Object.defineProperty(wrapper, prop, {
|
|
69
|
+
get: () => transport[prop],
|
|
70
|
+
set: (v) => {
|
|
71
|
+
transport[prop] = v;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
|
|
76
|
+
if (transport.setProtocolVersion) {
|
|
77
|
+
wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
|
|
78
|
+
}
|
|
79
|
+
return wrapper;
|
|
80
|
+
}
|