mcp-google-multi 6.0.0-alpha.9 → 6.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/accounts.d.ts +5 -0
- package/dist/accounts.js +12 -1
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +76 -0
- package/dist/arg-normalize.js +314 -0
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +96 -17
- package/dist/doctor.d.ts +9 -1
- package/dist/doctor.js +32 -4
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.js +43 -8
- package/dist/http-transport.d.ts +13 -1
- package/dist/http-transport.js +10 -4
- package/dist/index.js +113 -10
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/registry.d.ts +54 -2
- package/dist/registry.js +180 -7
- package/dist/scope-catalog.js +16 -0
- package/dist/services.d.ts +8 -0
- package/dist/services.js +28 -1
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +31 -3
- package/dist/tools/_errors.js +307 -43
- package/dist/tools/_local-files.js +7 -2
- package/dist/tools/account-wizard.d.ts +24 -1
- package/dist/tools/account-wizard.js +152 -51
- package/dist/tools/admin.js +7 -6
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +284 -0
- package/dist/tools/calendar.d.ts +7 -0
- package/dist/tools/calendar.js +75 -15
- package/dist/tools/chat.js +7 -4
- package/dist/tools/contacts.js +57 -30
- package/dist/tools/docs.js +56 -54
- package/dist/tools/drive.js +132 -70
- package/dist/tools/forms.js +7 -7
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +345 -214
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.js +4 -4
- package/dist/tools/generated/calendar.js +28 -28
- package/dist/tools/generated/chat.js +83 -56
- package/dist/tools/generated/classroom.js +258 -216
- package/dist/tools/generated/cloudidentity.js +76 -68
- package/dist/tools/generated/cloudsearch.js +40 -39
- package/dist/tools/generated/contacts.js +20 -11
- package/dist/tools/generated/drive.js +62 -53
- package/dist/tools/generated/drivelabels.js +60 -32
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.js +136 -109
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +8 -7
- package/dist/tools/generated/licensing.js +39 -23
- package/dist/tools/generated/meet.js +14 -14
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.js +3 -3
- package/dist/tools/generated/reseller.js +34 -28
- package/dist/tools/generated/script.js +29 -19
- package/dist/tools/generated/searchconsole.js +5 -3
- package/dist/tools/generated/sheets.js +10 -9
- package/dist/tools/generated/tasks.js +10 -4
- package/dist/tools/generated/vault.js +65 -56
- package/dist/tools/generated/workspaceevents.js +17 -16
- package/dist/tools/gmail.d.ts +10 -1
- package/dist/tools/gmail.js +50 -18
- package/dist/tools/google-api.js +48 -19
- package/dist/tools/meet.js +5 -2
- package/dist/tools/searchconsole.js +10 -10
- package/dist/tools/sheets.js +36 -48
- package/dist/tools/slides.js +6 -6
- package/dist/tools/tasks.js +16 -16
- package/dist/trim.d.ts +21 -0
- package/dist/trim.js +47 -0
- package/dist/usage-metrics.d.ts +155 -0
- package/dist/usage-metrics.js +603 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# mcp-google-multi
|
|
2
2
|
|
|
3
|
-
The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar, Sheets, Docs, Slides, Forms, Contacts, Tasks, Chat, Meet, Classroom, Vault, Admin and more — **every OAuth-reachable
|
|
3
|
+
The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar, Sheets, Docs, Slides, Forms, Contacts, Tasks, Chat, Meet, Analytics (GA4), Search Console, Classroom, Vault, Admin and more — **every OAuth-reachable API method** as a tool, across **multiple Google accounts** at once, from Claude Code or any MCP client.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
7
|
-
- 🧰 **Exhaustive** —
|
|
7
|
+
- 🧰 **Exhaustive** — 940 tools across 29 services, now including Google Analytics (GA4), + 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)
|
|
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). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
|
|
11
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)
|
|
12
12
|
|
|
13
13
|
## Quick setup
|
|
@@ -20,6 +20,8 @@ New to all this? It's written for someone who just installed Claude Code and has
|
|
|
20
20
|
npm install -g mcp-google-multi
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
Claude Desktop user? You can skip npm entirely: download the `mcp-google-multi.mcpb` bundle from the [latest release](https://github.com/bakissation/mcp-google-multi/releases/latest), double-click it (or drag it into Claude Desktop → Settings → Extensions), and fill in the values from step 2 when prompted.
|
|
24
|
+
|
|
23
25
|
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.
|
|
24
26
|
|
|
25
27
|
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:
|
|
@@ -51,7 +53,7 @@ New to all this? It's written for someone who just installed Claude Code and has
|
|
|
51
53
|
|
|
52
54
|
*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).
|
|
53
55
|
|
|
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)
|
|
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)
|
|
55
57
|
|
|
56
58
|
## Maintainer & credits
|
|
57
59
|
|
package/dist/accounts.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export declare function isAccountSetStale(): boolean;
|
|
|
30
30
|
* Snapshot from the initial load; enums widen only when the registry is
|
|
31
31
|
* rebuilt after a mutation (account_add, later slice). */
|
|
32
32
|
export declare const ACCOUNTS: string[];
|
|
33
|
+
/** One message for a mistyped alias, shared by every account schema. `aliases`
|
|
34
|
+
* is the SCHEMA's own snapshot, not the live registry: those are the values the
|
|
35
|
+
* enum actually accepts, and naming a runtime-added alias as valid while
|
|
36
|
+
* rejecting it would be a lie. */
|
|
37
|
+
export declare function unknownAliasMessage(aliases: readonly string[], selectors?: boolean): string;
|
|
33
38
|
/**
|
|
34
39
|
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
35
40
|
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
package/dist/accounts.js
CHANGED
|
@@ -278,6 +278,15 @@ export function isAccountSetStale() {
|
|
|
278
278
|
* Snapshot from the initial load; enums widen only when the registry is
|
|
279
279
|
* rebuilt after a mutation (account_add, later slice). */
|
|
280
280
|
export const ACCOUNTS = current.aliases;
|
|
281
|
+
/** One message for a mistyped alias, shared by every account schema. `aliases`
|
|
282
|
+
* is the SCHEMA's own snapshot, not the live registry: those are the values the
|
|
283
|
+
* enum actually accepts, and naming a runtime-added alias as valid while
|
|
284
|
+
* rejecting it would be a lie. */
|
|
285
|
+
export function unknownAliasMessage(aliases, selectors = false) {
|
|
286
|
+
const all = selectors ? '; "*" for all accounts' : '';
|
|
287
|
+
const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
|
|
288
|
+
return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
|
|
289
|
+
}
|
|
281
290
|
/**
|
|
282
291
|
* The `account` param schema, empty-registry-safe. A `z.enum` requires at least
|
|
283
292
|
* one value, so a fresh install (zero aliases) would throw at schema-build time
|
|
@@ -286,7 +295,9 @@ export const ACCOUNTS = current.aliases;
|
|
|
286
295
|
* are no aliases: no alias exists to enumerate, the server refuses to boot empty
|
|
287
296
|
* anyway, and dispatch validates the account against the live set. */
|
|
288
297
|
export function accountAliasSchemaFor(aliases) {
|
|
289
|
-
return aliases.length > 0
|
|
298
|
+
return aliases.length > 0
|
|
299
|
+
? z.enum(aliases, { error: () => unknownAliasMessage(aliases) })
|
|
300
|
+
: z.string();
|
|
290
301
|
}
|
|
291
302
|
/** Shared, load-time snapshot used by every tool's `account` field. */
|
|
292
303
|
export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
|
package/dist/api-probe.js
CHANGED
|
@@ -17,6 +17,9 @@ export const API_PROBES = [
|
|
|
17
17
|
{ service: 'chat', api: 'chat', url: 'https://chat.googleapis.com/v1/spaces?pageSize=1', scopePrefixes: [`${P}chat.`] },
|
|
18
18
|
{ service: 'meet', api: 'meet', url: 'https://meet.googleapis.com/v2/conferenceRecords?pageSize=1', scopePrefixes: [`${P}meetings.`] },
|
|
19
19
|
{ service: 'forms', api: 'forms', url: `https://forms.googleapis.com/v1/forms/${BOGUS_ID}`, scopePrefixes: [`${P}forms.`], notFoundMeansEnabled: true },
|
|
20
|
+
// Probes the Admin API only: the Data API has no no-arg read (every call
|
|
21
|
+
// needs a property id), so its enablement surfaces on first report instead.
|
|
22
|
+
{ service: 'analytics', api: 'analyticsadmin', url: 'https://analyticsadmin.googleapis.com/v1beta/accountSummaries?pageSize=1', scopePrefixes: [`${P}analytics`] },
|
|
20
23
|
];
|
|
21
24
|
export function planProbes(granted, probes = API_PROBES) {
|
|
22
25
|
return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/server";
|
|
2
|
+
import { type SiblingSpelling, type UnknownArgMode } from './arg-strict.js';
|
|
3
|
+
export declare function isSchemaValidationText(text: string): boolean;
|
|
4
|
+
export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
5
|
+
/** Declared scalar kind per schema key; drives value coercion on RENAMED keys
|
|
6
|
+
* only. Clients string-encode values for keys absent from the advertised
|
|
7
|
+
* schema, so a renamed key almost always arrives as a string — without
|
|
8
|
+
* coercion the rename would just move the -32602 from the key to the value. */
|
|
9
|
+
export type ArgKind = 'number' | 'boolean' | 'other';
|
|
10
|
+
export type ArgShape = ReadonlyMap<string, ArgKind>;
|
|
11
|
+
export declare function normalizeCallArguments(shape: ArgShape, args: Record<string, unknown>): {
|
|
12
|
+
args: Record<string, unknown>;
|
|
13
|
+
renamed: [string, string][];
|
|
14
|
+
};
|
|
15
|
+
export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): JSONRPCMessage;
|
|
16
|
+
export interface StrictArgOptions {
|
|
17
|
+
mode: UnknownArgMode;
|
|
18
|
+
/** full declared key list for a tool, in declaration order */
|
|
19
|
+
declaredFor: (tool: string) => readonly string[] | undefined;
|
|
20
|
+
/** sibling spellings of a concept elsewhere in the same service */
|
|
21
|
+
siblingsFor?: (tool: string, keys: string[]) => SiblingSpelling[];
|
|
22
|
+
/** Replacement text for the SDK's bare "Tool X not found". Still answered as
|
|
23
|
+
* a JSON-RPC error, which is what the spec prescribes for an unknown tool;
|
|
24
|
+
* only the message improves. */
|
|
25
|
+
unknownTool?: (tool: string) => string;
|
|
26
|
+
onDrop?: (tool: string, resolvedKeys: string[]) => void;
|
|
27
|
+
}
|
|
28
|
+
export type ScreenOutcome = {
|
|
29
|
+
action: 'forward';
|
|
30
|
+
msg: JSONRPCMessage;
|
|
31
|
+
} | {
|
|
32
|
+
action: 'reject';
|
|
33
|
+
response: JSONRPCMessage;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Screen a normalized tools/call for undeclared keys. `warn` forwards exactly
|
|
37
|
+
* as before and only reports; `reject` answers with the taxonomy envelope and
|
|
38
|
+
* never reaches the handler, so nothing is sent to Google on a guess.
|
|
39
|
+
*/
|
|
40
|
+
export declare function screenMessage(msg: JSONRPCMessage, opts: StrictArgOptions, log?: (line: string) => void): ScreenOutcome;
|
|
41
|
+
/** Wrap a server-side transport so tools/call argument keys are normalized
|
|
42
|
+
* before the SDK validates them. The Protocol assigns `onmessage` during
|
|
43
|
+
* connect(); the interceptor lives in that setter, so the wrapper works
|
|
44
|
+
* identically for stdio and (per-request, stateless) HTTP transports. */
|
|
45
|
+
export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void, strict?: StrictArgOptions): Transport;
|
|
46
|
+
export interface ValidationEnvelopeOptions {
|
|
47
|
+
/** Membership against the REGISTERED tool set: the name on the frame is
|
|
48
|
+
* ultimately client-supplied, and it must never enter an envelope unchecked. */
|
|
49
|
+
isKnownTool?: (name: string) => boolean;
|
|
50
|
+
/** The account the call WOULD have run as. The registry injects the default
|
|
51
|
+
* AFTER validation, so a validation failure never got that far. */
|
|
52
|
+
defaultAccount?: () => string | undefined;
|
|
53
|
+
}
|
|
54
|
+
interface PendingCall {
|
|
55
|
+
tool: string;
|
|
56
|
+
account?: string;
|
|
57
|
+
}
|
|
58
|
+
export declare function validationEnvelope(tool: string, issues: string, account: string | undefined): {
|
|
59
|
+
error: string;
|
|
60
|
+
message: string;
|
|
61
|
+
hint: string;
|
|
62
|
+
retriable: boolean;
|
|
63
|
+
account: string;
|
|
64
|
+
};
|
|
65
|
+
/** Floor for the OTHER bare prose the same SDK catch produces: an output-schema
|
|
66
|
+
* failure. Nothing is known about the cause, so it says exactly that. */
|
|
67
|
+
export declare function outputFailureEnvelope(tool: string, text: string, account: string | undefined): {
|
|
68
|
+
error: string;
|
|
69
|
+
message: string;
|
|
70
|
+
hint: string;
|
|
71
|
+
retriable: boolean;
|
|
72
|
+
account: string;
|
|
73
|
+
};
|
|
74
|
+
export declare function rewriteValidationFrame(message: JSONRPCMessage, pending: Map<string | number, PendingCall>, opts: ValidationEnvelopeOptions): JSONRPCMessage;
|
|
75
|
+
export declare function withValidationEnvelope(transport: Transport, opts?: ValidationEnvelopeOptions): Transport;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { screenArguments, unknownArgEnvelope, } from './arg-strict.js';
|
|
2
|
+
import { sliceClean } from './trim.js';
|
|
3
|
+
// The SDK synthesizes input-validation failures as isError RESULTS, skipping
|
|
4
|
+
// the handler entirely: no handler of ours runs, so no envelope exists and the
|
|
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 "{".
|
|
8
|
+
const SCHEMA_VALIDATION_TEXT = /^(MCP error -32602: )?Input validation error:/;
|
|
9
|
+
export function isSchemaValidationText(text) {
|
|
10
|
+
return SCHEMA_VALIDATION_TEXT.test(text);
|
|
11
|
+
}
|
|
12
|
+
/** Capture form of the v2 text. The tool-name class excludes ':' and ' ', so
|
|
13
|
+
* the first ": " after the name is unambiguously the delimiter. */
|
|
14
|
+
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. Both prefixes are
|
|
16
|
+
* SDK-authored; a HANDLER's free text is not, and must survive untouched
|
|
17
|
+
* (the account wizard answers in prose today). */
|
|
18
|
+
const SDK_OUTPUT_PROSE = /^(?:MCP error -32602: )?Output validation error: /;
|
|
19
|
+
// Wire-level tools/call argument normalization. Clients (LLMs) recurringly
|
|
20
|
+
// snake_case a camelCase parameter (thread_id for threadId) and burn a retry
|
|
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.
|
|
27
|
+
export function argNormalizationEnabled(env = process.env) {
|
|
28
|
+
return !/^(0|false|off|no)$/i.test((env.GOOGLE_ARG_NORMALIZE ?? '').trim());
|
|
29
|
+
}
|
|
30
|
+
const snakeToCamel = (key) => key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase());
|
|
31
|
+
function coerceRenamedValue(value, kind) {
|
|
32
|
+
if (typeof value !== 'string')
|
|
33
|
+
return value;
|
|
34
|
+
const v = value.trim();
|
|
35
|
+
if (kind === 'number' && /^-?\d+(\.\d+)?$/.test(v))
|
|
36
|
+
return Number(v);
|
|
37
|
+
if (kind === 'boolean' && /^(true|false)$/i.test(v))
|
|
38
|
+
return v.toLowerCase() === 'true';
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
export function normalizeCallArguments(shape, args) {
|
|
42
|
+
const renamed = [];
|
|
43
|
+
let out;
|
|
44
|
+
for (const key of Object.keys(args)) {
|
|
45
|
+
if (shape.has(key) || !key.includes('_'))
|
|
46
|
+
continue;
|
|
47
|
+
const camel = snakeToCamel(key);
|
|
48
|
+
if (camel !== key && shape.has(camel) && !(camel in args)) {
|
|
49
|
+
out ??= { ...args };
|
|
50
|
+
out[camel] = coerceRenamedValue(out[key], shape.get(camel));
|
|
51
|
+
delete out[key];
|
|
52
|
+
renamed.push([key, camel]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { args: out ?? args, renamed };
|
|
56
|
+
}
|
|
57
|
+
export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.write(`${l}\n`), onRename) {
|
|
58
|
+
const m = msg;
|
|
59
|
+
if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
|
|
60
|
+
return msg;
|
|
61
|
+
const args = m.params.arguments;
|
|
62
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
63
|
+
return msg;
|
|
64
|
+
const shape = shapeFor(m.params.name);
|
|
65
|
+
if (!shape)
|
|
66
|
+
return msg;
|
|
67
|
+
const { args: normalized, renamed } = normalizeCallArguments(shape, args);
|
|
68
|
+
if (renamed.length === 0)
|
|
69
|
+
return msg;
|
|
70
|
+
// Key names only — argument VALUES never reach the log.
|
|
71
|
+
log(`[args] ${m.params.name}: ${renamed.map(([f, t]) => `${f} -> ${t}`).join(', ')}`);
|
|
72
|
+
try {
|
|
73
|
+
onRename?.(m.params.name, renamed.length);
|
|
74
|
+
}
|
|
75
|
+
catch { /* observers never break dispatch */ }
|
|
76
|
+
return {
|
|
77
|
+
...msg,
|
|
78
|
+
params: { ...m.params, arguments: normalized },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Screen a normalized tools/call for undeclared keys. `warn` forwards exactly
|
|
83
|
+
* as before and only reports; `reject` answers with the taxonomy envelope and
|
|
84
|
+
* never reaches the handler, so nothing is sent to Google on a guess.
|
|
85
|
+
*/
|
|
86
|
+
export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}\n`)) {
|
|
87
|
+
if (opts.mode === 'off')
|
|
88
|
+
return { action: 'forward', msg };
|
|
89
|
+
const m = msg;
|
|
90
|
+
if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
|
|
91
|
+
return { action: 'forward', msg };
|
|
92
|
+
const args = m.params.arguments;
|
|
93
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
94
|
+
return { action: 'forward', msg };
|
|
95
|
+
const tool = m.params.name;
|
|
96
|
+
const declared = opts.declaredFor(tool);
|
|
97
|
+
// Unregistered tool. The spec keeps this a PROTOCOL error (unknown tool is
|
|
98
|
+
// not something the model can fix by adjusting arguments), so the channel
|
|
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.
|
|
102
|
+
if (!declared) {
|
|
103
|
+
if (!opts.unknownTool || m.id === undefined)
|
|
104
|
+
return { action: 'forward', msg };
|
|
105
|
+
return {
|
|
106
|
+
action: 'reject',
|
|
107
|
+
response: {
|
|
108
|
+
jsonrpc: '2.0',
|
|
109
|
+
id: m.id,
|
|
110
|
+
error: { code: -32602, message: opts.unknownTool(tool) },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const screened = screenArguments(tool, args, declared);
|
|
115
|
+
if (screened.unknown.length === 0 && screened.redundant.length === 0)
|
|
116
|
+
return { action: 'forward', msg };
|
|
117
|
+
const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
|
|
118
|
+
// Key names only; argument VALUES never reach the log.
|
|
119
|
+
log(`[args] ${tool}: undeclared ${all.join(', ')}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
|
|
120
|
+
try {
|
|
121
|
+
// Only ever a DECLARED key or the literal placeholder, so the metrics
|
|
122
|
+
// closed-vocabulary rule holds: the caller's key is never persisted.
|
|
123
|
+
opts.onDrop?.(tool, screened.unknown.map((u) => u.suggestions[0] ?? '_unmatched'));
|
|
124
|
+
}
|
|
125
|
+
catch { /* observers never break dispatch */ }
|
|
126
|
+
if (opts.mode === 'warn' || screened.unknown.length === 0)
|
|
127
|
+
return { action: 'forward', msg };
|
|
128
|
+
// Nothing to answer (a malformed notification): dispatch as before.
|
|
129
|
+
if (m.id === undefined)
|
|
130
|
+
return { action: 'forward', msg };
|
|
131
|
+
const account = args.account;
|
|
132
|
+
const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
|
|
133
|
+
? []
|
|
134
|
+
: (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
|
|
135
|
+
const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
|
|
136
|
+
return {
|
|
137
|
+
action: 'reject',
|
|
138
|
+
response: {
|
|
139
|
+
jsonrpc: '2.0',
|
|
140
|
+
id: m.id,
|
|
141
|
+
result: { content: [{ type: 'text', text: JSON.stringify(envelope) }], isError: true },
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Wrap a server-side transport so tools/call argument keys are normalized
|
|
146
|
+
* before the SDK validates them. The Protocol assigns `onmessage` during
|
|
147
|
+
* connect(); the interceptor lives in that setter, so the wrapper works
|
|
148
|
+
* identically for stdio and (per-request, stateless) HTTP transports. */
|
|
149
|
+
export function withArgNormalization(transport, shapeFor, log, onRename, strict) {
|
|
150
|
+
const wrapper = {
|
|
151
|
+
start: () => transport.start(),
|
|
152
|
+
send: (message, options) => transport.send(message, options),
|
|
153
|
+
close: () => transport.close(),
|
|
154
|
+
};
|
|
155
|
+
Object.defineProperty(wrapper, 'onmessage', {
|
|
156
|
+
get: () => transport.onmessage,
|
|
157
|
+
set: (handler) => {
|
|
158
|
+
transport.onmessage = handler
|
|
159
|
+
? (message, extra) => {
|
|
160
|
+
// Rename FIRST: a snake_case twin of a declared key is a fix, not
|
|
161
|
+
// an unknown argument, so it must never reach the screen.
|
|
162
|
+
const normalized = normalizeMessage(message, shapeFor, log, onRename);
|
|
163
|
+
if (!strict)
|
|
164
|
+
return handler(normalized, extra);
|
|
165
|
+
const outcome = screenMessage(normalized, strict, log);
|
|
166
|
+
if (outcome.action === 'forward')
|
|
167
|
+
return handler(outcome.msg, extra);
|
|
168
|
+
// Last-resort fallback: dispatch as before rather than hang the
|
|
169
|
+
// caller. The stderr line and the counter already fired above.
|
|
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.
|
|
172
|
+
const fallback = () => handler(normalized, extra);
|
|
173
|
+
try {
|
|
174
|
+
// Answer on the INNER transport so the metrics tap still sees the
|
|
175
|
+
// frame and clears its pending id.
|
|
176
|
+
void Promise.resolve(transport.send(outcome.response)).catch(fallback);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
fallback();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
: undefined;
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
for (const prop of ['onclose', 'onerror']) {
|
|
186
|
+
Object.defineProperty(wrapper, prop, {
|
|
187
|
+
get: () => transport[prop],
|
|
188
|
+
set: (v) => {
|
|
189
|
+
transport[prop] = v;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
|
|
194
|
+
if (transport.setProtocolVersion) {
|
|
195
|
+
wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
|
|
196
|
+
}
|
|
197
|
+
// v2-only, called by Protocol.connect() before start(). A no-op today (both
|
|
198
|
+
// sides default to the same exported constant), but a proxy that silently
|
|
199
|
+
// eats a member the SDK calls is a bug waiting for the first caller that
|
|
200
|
+
// passes supportedProtocolVersions explicitly.
|
|
201
|
+
if (transport.setSupportedProtocolVersions) {
|
|
202
|
+
wrapper.setSupportedProtocolVersions = (v) => transport.setSupportedProtocolVersions(v);
|
|
203
|
+
}
|
|
204
|
+
return wrapper;
|
|
205
|
+
}
|
|
206
|
+
const MAX_ACCOUNT_CHARS = 64;
|
|
207
|
+
const MAX_ISSUE_CHARS = 800;
|
|
208
|
+
const MAX_PROSE_CHARS = 500;
|
|
209
|
+
const ENVELOPE_PENDING_CAP = 1_000;
|
|
210
|
+
export function validationEnvelope(tool, issues, account) {
|
|
211
|
+
return {
|
|
212
|
+
error: 'validation_error',
|
|
213
|
+
message: `${tool} arguments failed validation: ${sliceClean(issues, MAX_ISSUE_CHARS)}`,
|
|
214
|
+
hint: `Fix the argument named in the message and call again. This tool's inputSchema in tools/list lists every accepted argument, its type and its allowed values. Nothing was sent to Google.`,
|
|
215
|
+
retriable: false,
|
|
216
|
+
account: account ?? '',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Floor for the OTHER bare prose the same SDK catch produces: an output-schema
|
|
220
|
+
* failure. Nothing is known about the cause, so it says exactly that. */
|
|
221
|
+
export function outputFailureEnvelope(tool, text, account) {
|
|
222
|
+
return {
|
|
223
|
+
error: 'internal',
|
|
224
|
+
message: `${tool} returned a result the protocol rejected: ${sliceClean(text, MAX_PROSE_CHARS)}`,
|
|
225
|
+
hint: 'A server-side failure, not a rejected argument. Retry once; if it repeats, the message above is the signal to report.',
|
|
226
|
+
retriable: false,
|
|
227
|
+
account: account ?? '',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
export function rewriteValidationFrame(message, pending, opts) {
|
|
231
|
+
const m = message;
|
|
232
|
+
// Responses only. A server-initiated REQUEST (elicitation, sampling) also
|
|
233
|
+
// carries an id, from a different id space, and evicting on it would drop a
|
|
234
|
+
// real pending call.
|
|
235
|
+
if (m.id === undefined || m.method !== undefined)
|
|
236
|
+
return message;
|
|
237
|
+
const call = pending.get(m.id);
|
|
238
|
+
pending.delete(m.id);
|
|
239
|
+
if (m.result?.isError !== true)
|
|
240
|
+
return message;
|
|
241
|
+
const content = m.result.content;
|
|
242
|
+
if (!content || content.length !== 1 || content[0]?.type !== 'text')
|
|
243
|
+
return message;
|
|
244
|
+
const text = content[0].text;
|
|
245
|
+
if (typeof text !== 'string' || text.startsWith('{'))
|
|
246
|
+
return message;
|
|
247
|
+
const parsed = isSchemaValidationText(text) ? VALIDATION_PROSE.exec(text) : null;
|
|
248
|
+
// Anything the SDK did not author is a handler's own answer. Leave it alone.
|
|
249
|
+
if (!parsed && !SDK_OUTPUT_PROSE.test(text))
|
|
250
|
+
return message;
|
|
251
|
+
const tool = parsed?.[1] ?? call?.tool;
|
|
252
|
+
if (!tool || (opts.isKnownTool && !opts.isKnownTool(tool)))
|
|
253
|
+
return message;
|
|
254
|
+
const account = call?.account ?? opts.defaultAccount?.();
|
|
255
|
+
const envelope = parsed
|
|
256
|
+
? validationEnvelope(tool, parsed[2], account)
|
|
257
|
+
: outputFailureEnvelope(tool, text, account);
|
|
258
|
+
return {
|
|
259
|
+
...message,
|
|
260
|
+
result: { ...m.result, content: [{ type: 'text', text: JSON.stringify(envelope) }] },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export function withValidationEnvelope(transport, opts = {}) {
|
|
264
|
+
const pending = new Map();
|
|
265
|
+
const wrapper = {
|
|
266
|
+
start: () => transport.start(),
|
|
267
|
+
send: (message, options) => {
|
|
268
|
+
let out = message;
|
|
269
|
+
try {
|
|
270
|
+
out = rewriteValidationFrame(message, pending, opts);
|
|
271
|
+
}
|
|
272
|
+
catch { /* the seam may never break the wire */ }
|
|
273
|
+
return transport.send(out, options);
|
|
274
|
+
},
|
|
275
|
+
close: () => transport.close(),
|
|
276
|
+
};
|
|
277
|
+
Object.defineProperty(wrapper, 'onmessage', {
|
|
278
|
+
get: () => transport.onmessage,
|
|
279
|
+
set: (handler) => {
|
|
280
|
+
transport.onmessage = handler
|
|
281
|
+
? (message, extra) => {
|
|
282
|
+
try {
|
|
283
|
+
const m = message;
|
|
284
|
+
if (m.method === 'tools/call' && m.id !== undefined && typeof m.params?.name === 'string') {
|
|
285
|
+
if (pending.size >= ENVELOPE_PENDING_CAP)
|
|
286
|
+
pending.clear();
|
|
287
|
+
const args = m.params.arguments;
|
|
288
|
+
const raw = args && typeof args === 'object' ? args.account : undefined;
|
|
289
|
+
pending.set(m.id, {
|
|
290
|
+
tool: m.params.name,
|
|
291
|
+
account: typeof raw === 'string' ? sliceClean(raw, MAX_ACCOUNT_CHARS) : undefined,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch { /* never break dispatch */ }
|
|
296
|
+
handler(message, extra);
|
|
297
|
+
}
|
|
298
|
+
: undefined;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
for (const prop of ['onclose', 'onerror']) {
|
|
302
|
+
Object.defineProperty(wrapper, prop, {
|
|
303
|
+
get: () => transport[prop],
|
|
304
|
+
set: (v) => {
|
|
305
|
+
transport[prop] = v;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
Object.defineProperty(wrapper, 'sessionId', { get: () => transport.sessionId });
|
|
310
|
+
if (transport.setProtocolVersion) {
|
|
311
|
+
wrapper.setProtocolVersion = (v) => transport.setProtocolVersion(v);
|
|
312
|
+
}
|
|
313
|
+
return wrapper;
|
|
314
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type UnknownArgMode = 'reject' | 'warn' | 'off';
|
|
2
|
+
/**
|
|
3
|
+
* `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
|
|
4
|
+
* back to `warn`, not to the default: an operator who misspells the setting
|
|
5
|
+
* has said nothing about which behavior they want, and guessing the strict
|
|
6
|
+
* one would turn a config typo into failing tool calls.
|
|
7
|
+
*/
|
|
8
|
+
export declare function unknownArgMode(env?: NodeJS.ProcessEnv): UnknownArgMode;
|
|
9
|
+
/** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
|
|
10
|
+
* the escape hatch is NOT one, because its open-endedness lives in the VALUES
|
|
11
|
+
* of queryParams/body, never in its six fixed top-level keys. */
|
|
12
|
+
export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
|
|
13
|
+
/** Metadata a client may legitimately attach. Measured against all registered
|
|
14
|
+
* tools: no declared key starts with `_` or contains `/`, so neither rule can
|
|
15
|
+
* shadow a real parameter. */
|
|
16
|
+
export declare function isExemptKey(key: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
|
|
19
|
+
* 0 same key modulo case and separators
|
|
20
|
+
* 1 every token of the unknown key appears in the declared key
|
|
21
|
+
* 2 substring containment of the flattened forms
|
|
22
|
+
* 3 a genuine typo by edit distance
|
|
23
|
+
* Plain edit distance alone cannot carry this: parentid -> parentfolderid is
|
|
24
|
+
* distance 6, well past any sane threshold, which is why tier 1 exists.
|
|
25
|
+
*/
|
|
26
|
+
export declare function suggestKeys(unknown: string, declared: readonly string[], limit?: number): string[];
|
|
27
|
+
export interface ScreenedKey {
|
|
28
|
+
/** the key the caller sent; for stderr and the agent-facing hint only */
|
|
29
|
+
sent: string;
|
|
30
|
+
/** best suggestions, always declared keys of the tool being called */
|
|
31
|
+
suggestions: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface ScreenResult {
|
|
34
|
+
unknown: ScreenedKey[];
|
|
35
|
+
/** dropped exactly as before: the caller also sent the declared key */
|
|
36
|
+
redundant: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Split a call's post-rename keys into declared, redundant-duplicate and
|
|
40
|
+
* genuinely unknown. `declared` is the tool's full declared key list.
|
|
41
|
+
*/
|
|
42
|
+
export declare function screenArguments(tool: string, args: Record<string, unknown>, declared: readonly string[]): ScreenResult;
|
|
43
|
+
export interface SiblingSpelling {
|
|
44
|
+
key: string;
|
|
45
|
+
tools: string[];
|
|
46
|
+
}
|
|
47
|
+
/** The agent-facing envelope. Values are never included, only key names. */
|
|
48
|
+
export declare function unknownArgEnvelope(tool: string, unknown: ScreenedKey[], declared: readonly string[], account: string | undefined, siblings?: SiblingSpelling[]): {
|
|
49
|
+
error: string;
|
|
50
|
+
message: string;
|
|
51
|
+
hint: string;
|
|
52
|
+
retriable: boolean;
|
|
53
|
+
account?: string;
|
|
54
|
+
};
|