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.
- package/dist/arg-normalize.d.ts +23 -1
- package/dist/arg-normalize.js +72 -2
- package/dist/arg-strict.d.ts +52 -0
- package/dist/arg-strict.js +144 -0
- package/dist/http-transport.d.ts +3 -1
- package/dist/http-transport.js +3 -1
- package/dist/index.js +18 -2
- package/dist/registry.d.ts +12 -0
- package/dist/registry.js +42 -0
- package/dist/tools/_coerce.d.ts +4 -3
- package/dist/tools/_coerce.js +4 -3
- package/dist/tools/drive.js +7 -7
- package/dist/usage-metrics.d.ts +9 -0
- package/dist/usage-metrics.js +32 -2
- package/package.json +1 -1
package/dist/arg-normalize.d.ts
CHANGED
|
@@ -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;
|
package/dist/arg-normalize.js
CHANGED
|
@@ -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) =>
|
|
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
|
+
}
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
253
|
-
|
|
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`);
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
package/dist/tools/_coerce.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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>;
|
package/dist/tools/_coerce.js
CHANGED
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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)))
|
package/dist/tools/drive.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
},
|
package/dist/usage-metrics.d.ts
CHANGED
|
@@ -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;
|
package/dist/usage-metrics.js
CHANGED
|
@@ -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.
|
|
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",
|