mcp-google-multi 6.0.0-alpha.32 → 6.0.0-alpha.33

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.
@@ -1,4 +1,5 @@
1
1
  import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/server";
2
+ import { type SiblingSpelling, type UnknownArgMode } from './arg-strict.js';
2
3
  export declare function argNormalizationEnabled(env?: NodeJS.ProcessEnv): boolean;
3
4
  /** Declared scalar kind per schema key; drives value coercion on RENAMED keys
4
5
  * only. Clients string-encode values for keys absent from the advertised
@@ -11,8 +12,29 @@ export declare function normalizeCallArguments(shape: ArgShape, args: Record<str
11
12
  renamed: [string, string][];
12
13
  };
13
14
  export declare function normalizeMessage(msg: JSONRPCMessage, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): JSONRPCMessage;
15
+ export interface StrictArgOptions {
16
+ mode: UnknownArgMode;
17
+ /** full declared key list for a tool, in declaration order */
18
+ declaredFor: (tool: string) => readonly string[] | undefined;
19
+ /** sibling spellings of a concept elsewhere in the same service */
20
+ siblingsFor?: (tool: string, keys: string[]) => SiblingSpelling[];
21
+ onDrop?: (tool: string, resolvedKeys: string[]) => void;
22
+ }
23
+ export type ScreenOutcome = {
24
+ action: 'forward';
25
+ msg: JSONRPCMessage;
26
+ } | {
27
+ action: 'reject';
28
+ response: JSONRPCMessage;
29
+ };
30
+ /**
31
+ * Screen a normalized tools/call for undeclared keys. `warn` forwards exactly
32
+ * as before and only reports; `reject` answers with the taxonomy envelope and
33
+ * never reaches the handler, so nothing is sent to Google on a guess.
34
+ */
35
+ export declare function screenMessage(msg: JSONRPCMessage, opts: StrictArgOptions, log?: (line: string) => void): ScreenOutcome;
14
36
  /** Wrap a server-side transport so tools/call argument keys are normalized
15
37
  * before the SDK validates them. The Protocol assigns `onmessage` during
16
38
  * connect(); the interceptor lives in that setter, so the wrapper works
17
39
  * identically for stdio and (per-request, stateless) HTTP transports. */
18
- export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void): Transport;
40
+ export declare function withArgNormalization(transport: Transport, shapeFor: (tool: string) => ArgShape | undefined, log?: (line: string) => void, onRename?: (tool: string, renames: number) => void, strict?: StrictArgOptions): Transport;
@@ -1,3 +1,4 @@
1
+ import { screenArguments, unknownArgEnvelope, } from './arg-strict.js';
1
2
  // Wire-level tools/call argument normalization. Clients (LLMs) recurringly
2
3
  // snake_case a camelCase parameter (thread_id for threadId) and burn a retry
3
4
  // on the -32602. A schema-level fix is off the table: the SDK advertises an
@@ -60,11 +61,61 @@ export function normalizeMessage(msg, shapeFor, log = (l) => process.stderr.writ
60
61
  params: { ...m.params, arguments: normalized },
61
62
  };
62
63
  }
64
+ /**
65
+ * Screen a normalized tools/call for undeclared keys. `warn` forwards exactly
66
+ * as before and only reports; `reject` answers with the taxonomy envelope and
67
+ * never reaches the handler, so nothing is sent to Google on a guess.
68
+ */
69
+ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}\n`)) {
70
+ if (opts.mode === 'off')
71
+ return { action: 'forward', msg };
72
+ const m = msg;
73
+ if (m.method !== 'tools/call' || typeof m.params?.name !== 'string')
74
+ return { action: 'forward', msg };
75
+ const args = m.params.arguments;
76
+ if (!args || typeof args !== 'object' || Array.isArray(args))
77
+ return { action: 'forward', msg };
78
+ const tool = m.params.name;
79
+ const declared = opts.declaredFor(tool);
80
+ // Unregistered tool: leave it to the SDK's own "not found".
81
+ if (!declared)
82
+ return { action: 'forward', msg };
83
+ const screened = screenArguments(tool, args, declared);
84
+ if (screened.unknown.length === 0 && screened.redundant.length === 0)
85
+ return { action: 'forward', msg };
86
+ const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
87
+ // Key names only; argument VALUES never reach the log.
88
+ log(`[args] ${tool}: undeclared ${all.join(', ')}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
89
+ try {
90
+ // Only ever a DECLARED key or the literal placeholder, so the metrics
91
+ // closed-vocabulary rule holds: the caller's key is never persisted.
92
+ opts.onDrop?.(tool, screened.unknown.map((u) => u.suggestions[0] ?? '_unmatched'));
93
+ }
94
+ catch { /* observers never break dispatch */ }
95
+ if (opts.mode === 'warn' || screened.unknown.length === 0)
96
+ return { action: 'forward', msg };
97
+ // Nothing to answer (a malformed notification): dispatch as before.
98
+ if (m.id === undefined)
99
+ return { action: 'forward', msg };
100
+ const account = args.account;
101
+ const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
102
+ ? []
103
+ : (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
104
+ const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
105
+ return {
106
+ action: 'reject',
107
+ response: {
108
+ jsonrpc: '2.0',
109
+ id: m.id,
110
+ result: { content: [{ type: 'text', text: JSON.stringify(envelope) }], isError: true },
111
+ },
112
+ };
113
+ }
63
114
  /** Wrap a server-side transport so tools/call argument keys are normalized
64
115
  * before the SDK validates them. The Protocol assigns `onmessage` during
65
116
  * connect(); the interceptor lives in that setter, so the wrapper works
66
117
  * identically for stdio and (per-request, stateless) HTTP transports. */
67
- export function withArgNormalization(transport, shapeFor, log, onRename) {
118
+ export function withArgNormalization(transport, shapeFor, log, onRename, strict) {
68
119
  const wrapper = {
69
120
  start: () => transport.start(),
70
121
  send: (message, options) => transport.send(message, options),
@@ -74,7 +125,26 @@ export function withArgNormalization(transport, shapeFor, log, onRename) {
74
125
  get: () => transport.onmessage,
75
126
  set: (handler) => {
76
127
  transport.onmessage = handler
77
- ? (message, extra) => handler(normalizeMessage(message, shapeFor, log, onRename), extra)
128
+ ? (message, extra) => {
129
+ // Rename FIRST: a snake_case twin of a declared key is a fix, not
130
+ // an unknown argument, so it must never reach the screen.
131
+ const normalized = normalizeMessage(message, shapeFor, log, onRename);
132
+ if (!strict)
133
+ return handler(normalized, extra);
134
+ const outcome = screenMessage(normalized, strict, log);
135
+ if (outcome.action === 'forward')
136
+ return handler(outcome.msg, extra);
137
+ try {
138
+ // Answer on the INNER transport so the metrics tap still sees the
139
+ // frame and clears its pending id.
140
+ void transport.send(outcome.response);
141
+ }
142
+ catch {
143
+ // Last-resort: dispatch as before rather than hang the caller.
144
+ // The stderr line and the counter already fired above.
145
+ handler(normalized, extra);
146
+ }
147
+ }
78
148
  : undefined;
79
149
  },
80
150
  });
@@ -0,0 +1,52 @@
1
+ export type UnknownArgMode = 'reject' | 'warn' | 'off';
2
+ /**
3
+ * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. Fail-open to `warn` on a bad
4
+ * value, because the safe state here is the one that changes no behavior.
5
+ */
6
+ export declare function unknownArgMode(env?: NodeJS.ProcessEnv): UnknownArgMode;
7
+ /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
8
+ * the escape hatch is NOT one, because its open-endedness lives in the VALUES
9
+ * of queryParams/body, never in its six fixed top-level keys. */
10
+ export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
11
+ /** Metadata a client may legitimately attach. Measured against all registered
12
+ * tools: no declared key starts with `_` or contains `/`, so neither rule can
13
+ * shadow a real parameter. */
14
+ export declare function isExemptKey(key: string): boolean;
15
+ /**
16
+ * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
17
+ * 0 same key modulo case and separators
18
+ * 1 every token of the unknown key appears in the declared key
19
+ * 2 substring containment of the flattened forms
20
+ * 3 a genuine typo by edit distance
21
+ * Plain edit distance alone cannot carry this: parentid -> parentfolderid is
22
+ * distance 6, well past any sane threshold, which is why tier 1 exists.
23
+ */
24
+ export declare function suggestKeys(unknown: string, declared: readonly string[], limit?: number): string[];
25
+ export interface ScreenedKey {
26
+ /** the key the caller sent; for stderr and the agent-facing hint only */
27
+ sent: string;
28
+ /** best suggestions, always declared keys of the tool being called */
29
+ suggestions: string[];
30
+ }
31
+ export interface ScreenResult {
32
+ unknown: ScreenedKey[];
33
+ /** dropped exactly as before: the caller also sent the declared key */
34
+ redundant: string[];
35
+ }
36
+ /**
37
+ * Split a call's post-rename keys into declared, redundant-duplicate and
38
+ * genuinely unknown. `declared` is the tool's full declared key list.
39
+ */
40
+ export declare function screenArguments(tool: string, args: Record<string, unknown>, declared: readonly string[]): ScreenResult;
41
+ export interface SiblingSpelling {
42
+ key: string;
43
+ tools: string[];
44
+ }
45
+ /** The agent-facing envelope. Values are never included, only key names. */
46
+ export declare function unknownArgEnvelope(tool: string, unknown: ScreenedKey[], declared: readonly string[], account: string | undefined, siblings?: SiblingSpelling[]): {
47
+ error: string;
48
+ message: string;
49
+ hint: string;
50
+ retriable: boolean;
51
+ account?: string;
52
+ };
@@ -0,0 +1,144 @@
1
+ // Unknown-argument screening (backlog item 14). zod strips undeclared keys
2
+ // before a handler runs, so a misremembered argument name used to produce a
3
+ // SUCCESS response with wrong behavior: `parentId` on drive_create_folder was
4
+ // dropped and the folder landed in My Drive root. Field-reported, then
5
+ // reproduced: the typo, the correct name and a wholly invented key all
6
+ // returned byte-identical responses.
7
+ //
8
+ // This module is pure and holds no registry reference. It never rewrites a key
9
+ // and never touches a VALUE: it only decides that a key is undeclared, and
10
+ // suggests what the caller probably meant. Rewriting would risk sending a real
11
+ // value to Google on a guess, which is strictly worse than refusing.
12
+ import { editDistance } from './scope-catalog.js';
13
+ /**
14
+ * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. Fail-open to `warn` on a bad
15
+ * value, because the safe state here is the one that changes no behavior.
16
+ */
17
+ export function unknownArgMode(env = process.env) {
18
+ const raw = (env.GOOGLE_ARG_UNKNOWN ?? '').trim().toLowerCase();
19
+ if (raw === '')
20
+ return DEFAULT_MODE;
21
+ if (raw === 'reject' || raw === 'warn' || raw === 'off')
22
+ return raw;
23
+ process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using ${DEFAULT_MODE}\n`);
24
+ return DEFAULT_MODE;
25
+ }
26
+ // Staged rollout: `warn` ships first so the change is pure observability, and
27
+ // the flip to `reject` is its own reviewable change.
28
+ const DEFAULT_MODE = 'warn';
29
+ /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
30
+ * the escape hatch is NOT one, because its open-endedness lives in the VALUES
31
+ * of queryParams/body, never in its six fixed top-level keys. */
32
+ export const STRICT_EXEMPT_TOOLS = new Set();
33
+ /** Metadata a client may legitimately attach. Measured against all registered
34
+ * tools: no declared key starts with `_` or contains `/`, so neither rule can
35
+ * shadow a real parameter. */
36
+ export function isExemptKey(key) {
37
+ return key.startsWith('_') || key.includes('/');
38
+ }
39
+ /** Generic words that must never be offered as a suggestion on containment
40
+ * alone; they match far too much to be useful. */
41
+ const GENERIC = new Set(['id', 'name', 'title', 'body', 'parent', 'text', 'content', 'type', 'value', 'key', 'data']);
42
+ const flatten = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
43
+ function tokens(s) {
44
+ return s
45
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
46
+ .split(/[\s_\-.]+/)
47
+ .map((t) => t.toLowerCase())
48
+ .filter(Boolean);
49
+ }
50
+ /**
51
+ * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
52
+ * 0 same key modulo case and separators
53
+ * 1 every token of the unknown key appears in the declared key
54
+ * 2 substring containment of the flattened forms
55
+ * 3 a genuine typo by edit distance
56
+ * Plain edit distance alone cannot carry this: parentid -> parentfolderid is
57
+ * distance 6, well past any sane threshold, which is why tier 1 exists.
58
+ */
59
+ export function suggestKeys(unknown, declared, limit = 2) {
60
+ const uFlat = flatten(unknown);
61
+ const uTok = tokens(unknown);
62
+ const tiers = [[], [], [], []];
63
+ for (const key of declared) {
64
+ const kFlat = flatten(key);
65
+ const kTok = tokens(key);
66
+ if (kFlat === uFlat) {
67
+ tiers[0].push({ key, rank: 0 });
68
+ continue;
69
+ }
70
+ if (uTok.length >= 2 && uTok.every((t) => kTok.includes(t))) {
71
+ tiers[1].push({ key, rank: Math.abs(kTok.length - uTok.length) });
72
+ continue;
73
+ }
74
+ if (uFlat.length >= 4 && !GENERIC.has(kFlat) && (kFlat.includes(uFlat) || uFlat.includes(kFlat))) {
75
+ tiers[2].push({ key, rank: Math.abs(kFlat.length - uFlat.length) });
76
+ continue;
77
+ }
78
+ const d = editDistance(uFlat, kFlat);
79
+ if (d <= Math.max(1, Math.floor(uFlat.length / 4)))
80
+ tiers[3].push({ key, rank: d });
81
+ }
82
+ const tier = tiers.find((t) => t.length > 0);
83
+ if (!tier)
84
+ return [];
85
+ // Stable: declaration order breaks rank ties.
86
+ return tier
87
+ .map((e, i) => ({ ...e, i }))
88
+ .sort((a, b) => a.rank - b.rank || a.i - b.i)
89
+ .slice(0, limit)
90
+ .map((e) => e.key);
91
+ }
92
+ /**
93
+ * Split a call's post-rename keys into declared, redundant-duplicate and
94
+ * genuinely unknown. `declared` is the tool's full declared key list.
95
+ */
96
+ export function screenArguments(tool, args, declared) {
97
+ const out = { unknown: [], redundant: [] };
98
+ if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
99
+ return out;
100
+ const declaredSet = new Set(declared);
101
+ for (const key of Object.keys(args)) {
102
+ if (declaredSet.has(key) || isExemptKey(key))
103
+ continue;
104
+ const suggestions = suggestKeys(key, declared);
105
+ // The caller sent both spellings, so the declared one already won and the
106
+ // outcome is what it has always been. Never fail a call that works today.
107
+ if (suggestions.some((s) => s in args)) {
108
+ out.redundant.push(key);
109
+ continue;
110
+ }
111
+ out.unknown.push({ sent: key, suggestions });
112
+ }
113
+ return out;
114
+ }
115
+ /** The agent-facing envelope. Values are never included, only key names. */
116
+ export function unknownArgEnvelope(tool, unknown, declared, account, siblings = []) {
117
+ const names = unknown.map((u) => `"${u.sent}"`).join(', ');
118
+ const accepts = `This tool accepts: ${declared.join(', ')}.`;
119
+ const suggested = unknown.flatMap((u) => u.suggestions);
120
+ let hint;
121
+ if (suggested.length > 0) {
122
+ const did = unknown
123
+ .filter((u) => u.suggestions.length > 0)
124
+ .map((u) => u.suggestions.map((s) => `"${s}"`).join(' or '))
125
+ .join(', ');
126
+ hint = `Did you mean ${did}? ${accepts}`;
127
+ }
128
+ else if (siblings.length > 0) {
129
+ const spelled = siblings
130
+ .map((s) => `"${s.key}" (${s.tools.slice(0, 3).join(', ')})`)
131
+ .join(' and ');
132
+ hint = `${accepts} Other tools in this service spell a similar argument ${spelled}.`;
133
+ }
134
+ else {
135
+ hint = accepts;
136
+ }
137
+ return {
138
+ error: 'unknown_argument',
139
+ message: `${tool} does not accept ${names}. Nothing was sent to Google.`,
140
+ hint,
141
+ retriable: false,
142
+ ...(account !== undefined ? { account } : {}),
143
+ };
144
+ }
@@ -1,7 +1,7 @@
1
1
  import type { McpServer, Transport } from "@modelcontextprotocol/server";
2
2
  import { type IncomingMessage, type ServerResponse } from 'node:http';
3
3
  import type { HttpConfig } from './http-config.js';
4
- import { type ArgShape } from './arg-normalize.js';
4
+ import { type ArgShape, type StrictArgOptions } from './arg-normalize.js';
5
5
  export type AuthOutcome = {
6
6
  ok: true;
7
7
  } | {
@@ -35,6 +35,8 @@ export interface HttpHostOptions {
35
35
  metricsTap?: (t: Transport) => Transport;
36
36
  /** Usage-metrics argfix observer, forwarded into arg normalization. */
37
37
  onArgRename?: (tool: string, renames: number) => void;
38
+ /** Unknown-argument screening (arg-strict.ts); absent = off. */
39
+ strictArgs?: StrictArgOptions;
38
40
  }
39
41
  export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
40
42
  /** Front guard: an Origin, if present, must be allowlisted; a Host must be
@@ -183,7 +183,9 @@ export class HttpTransportHost {
183
183
  timer.unref?.();
184
184
  });
185
185
  const tapped = this.opts.metricsTap ? this.opts.metricsTap(transport) : transport;
186
- await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(tapped, this.opts.argShapeFor, this.opts.log, this.opts.onArgRename) : tapped);
186
+ await this.opts.server.connect(this.opts.argShapeFor || this.opts.strictArgs
187
+ ? withArgNormalization(tapped, this.opts.argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, this.opts.strictArgs)
188
+ : tapped);
187
189
  // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
188
190
  // race, an orphaned handler settling later must not surface as an unhandled
189
191
  // rejection — but a genuine dispatch error still propagates (rethrown below).
package/dist/index.js CHANGED
@@ -21,12 +21,26 @@ import { buildIdentityContext } from './identity.js';
21
21
  import { registerSetupPrompt } from './setup-prompt.js';
22
22
  import { applyNetTuning } from './net-tuning.js';
23
23
  import { argNormalizationEnabled, withArgNormalization } from './arg-normalize.js';
24
+ import { unknownArgMode } from './arg-strict.js';
24
25
  import { envValueSource } from './env-load.js';
25
26
  import { loadConfigFile } from './config-file.js';
26
27
  import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
27
28
  applyNetTuning();
28
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
30
  const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
31
+ /** Unknown-argument screening options, or undefined when the feature is off.
32
+ * Shared by both transports so a mistyped argument behaves identically. */
33
+ function strictArgOptions(registry, metrics) {
34
+ const mode = unknownArgMode();
35
+ if (mode === 'off')
36
+ return undefined;
37
+ return {
38
+ mode,
39
+ declaredFor: (tool) => registry.declaredKeys(tool),
40
+ siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
41
+ onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
42
+ };
43
+ }
30
44
  function buildRegistry(server, ctx, mode, metrics = null) {
31
45
  const policy = ctx.policy;
32
46
  const registry = new ToolRegistry(server, policy, mode, metrics);
@@ -249,8 +263,9 @@ async function main() {
249
263
  const { tapUsageMetrics } = await import('./metrics-tap.js');
250
264
  transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
251
265
  }
252
- await server.connect(argNormalizationEnabled()
253
- ? withArgNormalization(transport, (n) => registry.argShape(n), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined)
266
+ const strictStdio = strictArgOptions(registry, stdioMetrics);
267
+ await server.connect(argNormalizationEnabled() || strictStdio
268
+ ? withArgNormalization(transport, (n) => registry.argShape(n), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, strictStdio)
254
269
  : transport);
255
270
  }
256
271
  if (wantHttp) {
@@ -351,6 +366,7 @@ async function main() {
351
366
  argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
352
367
  metricsTap: httpTap,
353
368
  onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
369
+ strictArgs: strictArgOptions(registry, httpMetrics),
354
370
  });
355
371
  await host.start();
356
372
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
@@ -55,6 +55,18 @@ export declare class ToolRegistry {
55
55
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
56
56
  * normalization; the kind drives value coercion on renamed keys). */
57
57
  argShape(name: string): ArgShape | undefined;
58
+ /** Declared argument keys for a tool, in declaration order; undefined when
59
+ * the tool is not registered. Backs unknown-argument screening, which needs
60
+ * the full key list rather than argShape's scalar-kind subset view. */
61
+ declaredKeys(name: string): readonly string[] | undefined;
62
+ /** Keys spelling a similar concept elsewhere in the same service, for the
63
+ * hint on a call whose key matched nothing. Kept only when a key is declared
64
+ * by at least two tools in the service or by a curated one, so one-off
65
+ * generated parameters do not become advice. */
66
+ siblingSpellings(tool: string, unknownKeys: string[]): Array<{
67
+ key: string;
68
+ tools: string[];
69
+ }>;
58
70
  catalog(service: string, query?: string): CatalogOperation[];
59
71
  /** The metrics recorder, for hook sites (escape hatch); null when off. */
60
72
  get usageMetrics(): Metrics | null;
package/dist/registry.js CHANGED
@@ -4,6 +4,7 @@ import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
4
4
  import { compactResult, trimEnabled } from './trim.js';
5
5
  import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
6
6
  import { MAX_RESPONSE_CHARS } from './executor.js';
7
+ import { suggestKeys } from './arg-strict.js';
7
8
  // Client-side result budget advertised for tools that do not declare their own
8
9
  // (fat readers do; see trim.ts). ~50k chars stays well inside a default client
9
10
  // context limit while leaving room for real list payloads.
@@ -239,6 +240,47 @@ export class ToolRegistry {
239
240
  this.argShapeCache.set(name, shape);
240
241
  return shape;
241
242
  }
243
+ /** Declared argument keys for a tool, in declaration order; undefined when
244
+ * the tool is not registered. Backs unknown-argument screening, which needs
245
+ * the full key list rather than argShape's scalar-kind subset view. */
246
+ declaredKeys(name) {
247
+ const entry = this.tools.find((t) => t.name === name);
248
+ return entry ? Object.keys(entry.inputShape) : undefined;
249
+ }
250
+ /** Keys spelling a similar concept elsewhere in the same service, for the
251
+ * hint on a call whose key matched nothing. Kept only when a key is declared
252
+ * by at least two tools in the service or by a curated one, so one-off
253
+ * generated parameters do not become advice. */
254
+ siblingSpellings(tool, unknownKeys) {
255
+ const self = this.tools.find((t) => t.name === tool);
256
+ if (!self)
257
+ return [];
258
+ const declared = new Set(Object.keys(self.inputShape));
259
+ const byKey = new Map();
260
+ for (const t of this.tools) {
261
+ if (t.service !== self.service || t.name === tool)
262
+ continue;
263
+ for (const key of Object.keys(t.inputShape)) {
264
+ if (declared.has(key))
265
+ continue;
266
+ const e = byKey.get(key) ?? { tools: [], curated: false };
267
+ e.tools.push(t.name);
268
+ if (!t.generated)
269
+ e.curated = true;
270
+ byKey.set(key, e);
271
+ }
272
+ }
273
+ const candidates = [...byKey.entries()].filter(([, e]) => e.tools.length >= 2 || e.curated);
274
+ // Reuse the tiered matcher rather than a substring test: the motivating
275
+ // case (parentId against parentFolderId) fails containment and edit
276
+ // distance alike, which is the whole reason that matcher exists.
277
+ const keys = candidates.map(([key]) => key);
278
+ const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
279
+ return candidates
280
+ .filter(([key]) => hits.has(key))
281
+ .slice(0, 2)
282
+ .map(([key, e]) => ({ key, tools: e.tools }));
283
+ }
242
284
  catalog(service, query) {
243
285
  const q = query?.trim().toLowerCase();
244
286
  return this.tools
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  export declare function coerceArray<T extends z.ZodTypeAny>(element: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodArray<T>>;
3
3
  export declare function coerceJson<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
4
- /** Numeric args arrive string-encoded from some clients; over stdio the SDK
5
- * validates the schema directly (the HTTP transport's arg-normalization layer
6
- * never runs), so the coercion must live in the schema itself. */
4
+ /** Numeric args arrive string-encoded from some clients. Arg normalization
5
+ * runs on BOTH transports, but it only coerces keys it RENAMED, so a value
6
+ * sent under the correct key never passes through it and the coercion has to
7
+ * live in the schema. */
7
8
  export declare function coerceNumber<T extends z.ZodTypeAny>(schema: T): z.ZodPipe<z.ZodTransform<unknown, unknown>, T>;
8
9
  export declare const coerceBoolean: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodBoolean>;
@@ -22,9 +22,10 @@ export function coerceArray(element) {
22
22
  export function coerceJson(schema) {
23
23
  return z.preprocess((val) => (typeof val === 'string' ? parseJsonLoose(val) : val), schema);
24
24
  }
25
- /** Numeric args arrive string-encoded from some clients; over stdio the SDK
26
- * validates the schema directly (the HTTP transport's arg-normalization layer
27
- * never runs), so the coercion must live in the schema itself. */
25
+ /** Numeric args arrive string-encoded from some clients. Arg normalization
26
+ * runs on BOTH transports, but it only coerces keys it RENAMED, so a value
27
+ * sent under the correct key never passes through it and the coercion has to
28
+ * live in the schema. */
28
29
  export function coerceNumber(schema) {
29
30
  return z.preprocess((val) => {
30
31
  if (typeof val === 'string' && val.trim() !== '' && !Number.isNaN(Number(val)))
@@ -273,7 +273,7 @@ export function registerDriveTools(server) {
273
273
  description: 'List files in a Google Drive folder or root',
274
274
  inputSchema: {
275
275
  account: accountEnum.describe('Google account alias'),
276
- folderId: z.string().optional().describe('Folder ID (omit for root)'),
276
+ folderId: z.string().optional().describe('Folder ID to list, omit for root. Named folderId here, not parentFolderId'),
277
277
  maxResults: z.number().min(1).max(100).default(50).optional()
278
278
  .describe('Max results to return (default: 50)'),
279
279
  },
@@ -307,7 +307,7 @@ export function registerDriveTools(server) {
307
307
  filename: z.string().describe('Name as it appears in Drive'),
308
308
  mimeType: z.string().optional().describe('Source MIME type of the local file (inferred from extension if omitted). With `convertTo`, this is the format Drive imports from.'),
309
309
  convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('Convert the upload into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted). E.g. upload .md/.html/.docx/.txt with convertTo=document to get a real Google Doc. Source must be an importable format. Omit to store the file as-is.'),
310
- parentFolderId: z.string().optional().describe('Parent folder ID (defaults to My Drive root)'),
310
+ parentFolderId: z.string().optional().describe('Parent folder ID, not parentId. Defaults to My Drive root'),
311
311
  },
312
312
  }, async ({ account, localPath, filename, mimeType: mimeTypeArg, convertTo, parentFolderId }) => {
313
313
  try {
@@ -402,7 +402,7 @@ export function registerDriveTools(server) {
402
402
  inputSchema: {
403
403
  account: accountEnum.describe('Google account alias'),
404
404
  name: z.string().describe('Folder name'),
405
- parentFolderId: z.string().optional().describe('Parent folder ID (defaults to My Drive root)'),
405
+ parentFolderId: z.string().optional().describe('Parent folder ID, not parentId. Defaults to My Drive root'),
406
406
  },
407
407
  }, async ({ account, name, parentFolderId }) => {
408
408
  try {
@@ -431,7 +431,7 @@ export function registerDriveTools(server) {
431
431
  account: accountEnum.describe('Google account alias'),
432
432
  fileId: z.string().describe('Google Drive file ID'),
433
433
  newName: z.string().optional().describe('New filename'),
434
- newParentFolderId: z.string().optional().describe('Move to this folder'),
434
+ newParentFolderId: z.string().optional().describe('Move to this folder, named newParentFolderId here, not parentFolderId'),
435
435
  localPath: z.string().optional().describe('Replace file content with this local file (path on the machine running the server)'),
436
436
  mimeType: z.string().optional().describe('MIME type of the replacement file (required if localPath is provided)'),
437
437
  convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted).'),
@@ -563,7 +563,7 @@ export function registerDriveTools(server) {
563
563
  account: accountEnum.describe('Google account alias'),
564
564
  fileId: z.string().describe('Google Drive file ID to copy'),
565
565
  newName: z.string().optional().describe('Name for the copy (default: "Copy of <original>")'),
566
- parentFolderId: z.string().optional().describe('Where to put the copy (default: same folder)'),
566
+ parentFolderId: z.string().optional().describe('Where to put the copy, not parentId. Default: same folder'),
567
567
  },
568
568
  }, async ({ account, fileId, newName, parentFolderId }) => {
569
569
  try {
@@ -591,7 +591,7 @@ export function registerDriveTools(server) {
591
591
  inputSchema: {
592
592
  account: accountEnum.describe('Google account alias'),
593
593
  fileId: z.string().describe('Google Drive file ID'),
594
- newParentFolderId: z.string().describe('Destination folder ID'),
594
+ newParentFolderId: z.string().describe('Destination folder ID, named newParentFolderId here, not parentFolderId'),
595
595
  },
596
596
  }, async ({ account, fileId, newParentFolderId }) => {
597
597
  try {
@@ -1212,7 +1212,7 @@ export function registerDriveTools(server) {
1212
1212
  fromAccount: requiredAccountEnum.describe('Source account alias'),
1213
1213
  toAccount: requiredAccountEnum.describe('Target account alias'),
1214
1214
  fileId: z.string().describe('File ID in the source account (folders are not supported)'),
1215
- parentFolderId: z.string().optional().describe('Target folder ID (default: target My Drive root)'),
1215
+ parentFolderId: z.string().optional().describe('Target folder ID, not parentId. Default: target My Drive root'),
1216
1216
  newName: z.string().optional().describe('Rename the copy (default: keep the source name)'),
1217
1217
  move: coerceBoolean.optional().describe('Trash the source after a successful copy (delete-gated)'),
1218
1218
  },
@@ -46,6 +46,8 @@ interface ToolAgg {
46
46
  calls: number;
47
47
  } & Record<string, number>;
48
48
  argfix?: number;
49
+ /** undeclared keys, counted by the DECLARED key they resolve to */
50
+ argdrop?: Record<string, number>;
49
51
  }
50
52
  export interface DayAgg {
51
53
  v: 1;
@@ -119,6 +121,13 @@ export declare class Metrics {
119
121
  recordEscapeMethod(idOrNull: string | null): void;
120
122
  /** Search instrument: post-resolution SUPPORTED_APIS keys, or null on failure. */
121
123
  recordSearchApi(resolvedApis: string[] | null): void;
124
+ /**
125
+ * Undeclared argument keys seen on a call. `resolvedKeys` carries only the
126
+ * SUGGESTED (declared) key or the literal `_unmatched`, never the caller's
127
+ * key, so the closed-vocabulary guarantee holds: a client cannot write an
128
+ * invented string to disk through this path.
129
+ */
130
+ recordArgDrop(tool: string, resolvedKeys: string[]): void;
122
131
  recordArgFix(tool: string, by?: number): void;
123
132
  /** Protocol-level failures (no handler ran); wired by the transport tap. */
124
133
  recordRpc(kind: 'schema_validation' | 'tool_not_found' | number, tool?: string): void;
@@ -30,6 +30,9 @@ const CHARS_BOUNDS = [64, 256, 1024, 4096, 16384, 65536];
30
30
  const METHOD_ID_RE = /^[a-z][a-zA-Z0-9]*(\.[a-zA-Z0-9]+)+$/;
31
31
  const METHOD_ID_MAX = 128;
32
32
  const TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
33
+ // Declared argument keys are camelCase identifiers, some dotted
34
+ // (groupKey.id, debugOptions.enableDebugging).
35
+ const ARG_KEY_RE = /^[A-Za-z][A-Za-z0-9_.]{0,63}$/;
33
36
  /** Union of every `error:` slug literal emitted anywhere in src/ (kept honest
34
37
  * by a set-equality grep test). Anything outside buckets to `other`. */
35
38
  export const KNOWN_ERROR_SLUGS = new Set([
@@ -40,7 +43,7 @@ export const KNOWN_ERROR_SLUGS = new Set([
40
43
  'invalid_grant', 'invalid_params', 'invalid_query', 'invalid_request',
41
44
  'invalid_scope', 'network_error', 'not_found', 'rate_limited',
42
45
  'reauth_required', 'recipient_not_allowed', 'too_large', 'toolset_disabled', 'unknown_api',
43
- 'unknown_method', 'unsupported_grant_type', 'unsupported_type',
46
+ 'unknown_argument', 'unknown_method', 'unsupported_grant_type', 'unsupported_type',
44
47
  'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
45
48
  ]);
46
49
  const RPC_CODES = new Set([-32700, -32600, -32601, -32603]);
@@ -138,6 +141,8 @@ export function mergeDay(a, b) {
138
141
  const argfix = num(x?.argfix, y?.argfix);
139
142
  if (argfix > 0)
140
143
  t.argfix = argfix;
144
+ if (x?.argdrop || y?.argdrop)
145
+ t.argdrop = map(x?.argdrop, y?.argdrop);
141
146
  out.tools[name] = t;
142
147
  }
143
148
  const slugSet = new Set([...Object.keys(a.hints), ...Object.keys(b.hints)]);
@@ -183,8 +188,11 @@ function applyCaps(day) {
183
188
  kept.other = (kept.other ?? 0) + dropped;
184
189
  return kept;
185
190
  };
186
- for (const t of Object.values(day.tools))
191
+ for (const t of Object.values(day.tools)) {
187
192
  t.err = cap(t.err, ERR_SLUG_CAP);
193
+ if (t.argdrop)
194
+ t.argdrop = cap(t.argdrop, ERR_SLUG_CAP);
195
+ }
188
196
  // Bounded by the registered tool set via the tap's membership test, but
189
197
  // capped anyway: this map is the only one fed by a wire-supplied key.
190
198
  day.validation = cap(day.validation, VALIDATION_CAP);
@@ -416,6 +424,28 @@ export class Metrics {
416
424
  }
417
425
  catch { /* never throws outward */ }
418
426
  }
427
+ /**
428
+ * Undeclared argument keys seen on a call. `resolvedKeys` carries only the
429
+ * SUGGESTED (declared) key or the literal `_unmatched`, never the caller's
430
+ * key, so the closed-vocabulary guarantee holds: a client cannot write an
431
+ * invented string to disk through this path.
432
+ */
433
+ recordArgDrop(tool, resolvedKeys) {
434
+ try {
435
+ this.rolloverIfNeeded();
436
+ if (!TOOL_NAME_RE.test(tool))
437
+ return;
438
+ const t = (this.deltas.tools[tool] ??= { n: 0, err: {}, hint: 0, lat: {} });
439
+ const map = (t.argdrop ??= {});
440
+ for (const key of resolvedKeys) {
441
+ if (key !== '_unmatched' && !ARG_KEY_RE.test(key))
442
+ continue;
443
+ addInto(map, key);
444
+ }
445
+ this.dirty = true;
446
+ }
447
+ catch { /* never throws outward */ }
448
+ }
419
449
  recordArgFix(tool, by = 1) {
420
450
  try {
421
451
  this.rolloverIfNeeded();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.32",
3
+ "version": "6.0.0-alpha.33",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",