mcp-google-multi 6.0.0-alpha.56 → 6.0.0-alpha.57
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/accounts.d.ts +5 -0
- package/dist/accounts.js +23 -4
- package/dist/arg-normalize.js +3 -3
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +34 -4
- package/dist/compose.js +2 -2
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +6 -1
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +33 -10
- package/dist/http-transport.js +7 -0
- package/dist/index.js +2 -14
- package/dist/oauth-as.d.ts +3 -3
- package/dist/oauth-as.js +8 -0
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +16 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +23 -12
- package/dist/scope-catalog.d.ts +8 -0
- package/dist/scope-catalog.js +17 -5
- package/dist/services.d.ts +7 -7
- package/dist/services.js +36 -7
- package/dist/tools/account-wizard.d.ts +1 -1
- package/dist/tools/account-wizard.js +24 -11
- package/dist/tools/generated/_shared.d.ts +1 -1
- package/dist/tools/generated/_shared.js +6 -5
- package/dist/tools/generated/admin.js +132 -132
- package/dist/tools/generated/analytics.js +62 -62
- package/dist/tools/generated/appsmarket.js +2 -2
- package/dist/tools/generated/calendar.js +27 -27
- package/dist/tools/generated/chat.js +47 -47
- package/dist/tools/generated/classroom.js +104 -104
- package/dist/tools/generated/cloudidentity.js +66 -66
- package/dist/tools/generated/cloudsearch.js +49 -49
- package/dist/tools/generated/contacts.js +14 -14
- package/dist/tools/generated/drive.js +35 -35
- package/dist/tools/generated/driveactivity.js +1 -1
- package/dist/tools/generated/drivelabels.js +26 -26
- package/dist/tools/generated/forms.js +3 -3
- package/dist/tools/generated/gmail.js +58 -58
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/keep.js +7 -7
- package/dist/tools/generated/licensing.js +7 -7
- package/dist/tools/generated/meet.js +13 -13
- package/dist/tools/generated/postmaster.js +4 -4
- package/dist/tools/generated/reseller.js +17 -17
- package/dist/tools/generated/script.js +16 -16
- package/dist/tools/generated/searchconsole.js +1 -1
- package/dist/tools/generated/sheets.js +7 -7
- package/dist/tools/generated/tasks.js +2 -2
- package/dist/tools/generated/vault.js +33 -33
- package/dist/tools/generated/workspaceevents.js +15 -15
- package/dist/tools/google-api.js +16 -7
- package/package.json +1 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -56,6 +56,11 @@ export declare const accountAliasSchema: z.ZodType<string>;
|
|
|
56
56
|
* permissive (same empty-safe rule as above); dispatch still resolves the
|
|
57
57
|
* alias against the live registry. */
|
|
58
58
|
export declare function accountArgLive(aliases: () => readonly string[]): z.ZodType<string>;
|
|
59
|
+
/** The live membership check behind accountArgLive and the fan-out field;
|
|
60
|
+
* `selectors` also admits "*". An empty set stays permissive. */
|
|
61
|
+
export declare function liveAccountCheck(aliases: () => readonly string[], selectors: boolean): z.core.$ZodCheck<string>;
|
|
62
|
+
/** True for a field built on liveAccountCheck, optional or not. */
|
|
63
|
+
export declare function isLiveAccountField(field: unknown): boolean;
|
|
59
64
|
/**
|
|
60
65
|
* BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
|
|
61
66
|
* bootstraps via env / `migrate-config` / `account import` / `auth` first. The
|
package/dist/accounts.js
CHANGED
|
@@ -287,6 +287,8 @@ export const ACCOUNTS = current.aliases;
|
|
|
287
287
|
* enum actually accepts, and naming a runtime-added alias as valid while
|
|
288
288
|
* rejecting it would be a lie. */
|
|
289
289
|
export function unknownAliasMessage(aliases, selectors = false) {
|
|
290
|
+
if (aliases.length === 0)
|
|
291
|
+
return 'No Google account is configured yet. Add one with account_add.';
|
|
290
292
|
const all = selectors ? '; "*" for all accounts' : '';
|
|
291
293
|
const csv = selectors && aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
|
|
292
294
|
return `Unknown account alias. Valid: ${aliases.join(', ')}${all}${csv}. Run account_list if accounts changed since this server started.`;
|
|
@@ -312,12 +314,29 @@ export const accountAliasSchema = accountAliasSchemaFor(ACCOUNTS);
|
|
|
312
314
|
* permissive (same empty-safe rule as above); dispatch still resolves the
|
|
313
315
|
* alias against the live registry. */
|
|
314
316
|
export function accountArgLive(aliases) {
|
|
315
|
-
return z.string().
|
|
317
|
+
return z.string().check(liveAccountCheck(aliases, false));
|
|
318
|
+
}
|
|
319
|
+
// The check instance survives the clones .describe() and .optional() make,
|
|
320
|
+
// so it is what marks a field as an account selector for the registry.
|
|
321
|
+
const LIVE_ACCOUNT_CHECKS = new WeakSet();
|
|
322
|
+
/** The live membership check behind accountArgLive and the fan-out field;
|
|
323
|
+
* `selectors` also admits "*". An empty set stays permissive. */
|
|
324
|
+
export function liveAccountCheck(aliases, selectors) {
|
|
325
|
+
const check = z.superRefine((value, ctx) => {
|
|
316
326
|
const current = aliases();
|
|
317
|
-
if (current.length
|
|
318
|
-
|
|
319
|
-
}
|
|
327
|
+
if (current.length === 0 || current.includes(value) || (selectors && value === '*'))
|
|
328
|
+
return;
|
|
329
|
+
ctx.addIssue({ code: 'custom', message: unknownAliasMessage(current, selectors) });
|
|
320
330
|
});
|
|
331
|
+
LIVE_ACCOUNT_CHECKS.add(check);
|
|
332
|
+
return check;
|
|
333
|
+
}
|
|
334
|
+
/** True for a field built on liveAccountCheck, optional or not. */
|
|
335
|
+
export function isLiveAccountField(field) {
|
|
336
|
+
let def = field?._zod?.def;
|
|
337
|
+
if (def?.type === 'optional')
|
|
338
|
+
def = def.innerType?._zod?.def;
|
|
339
|
+
return def?.checks?.some((c) => LIVE_ACCOUNT_CHECKS.has(c)) ?? false;
|
|
321
340
|
}
|
|
322
341
|
/**
|
|
323
342
|
* BR-4: the stdio/http SERVER never boots with an empty registry — a fresh user
|
package/dist/arg-normalize.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { screenArguments, unknownArgEnvelope, } from './arg-strict.js';
|
|
1
|
+
import { echoName, echoNames, screenArguments, unknownArgEnvelope, } from './arg-strict.js';
|
|
2
2
|
import { sliceClean } from './trim.js';
|
|
3
3
|
// The SDK synthesizes input-validation failures as isError RESULTS, skipping
|
|
4
4
|
// the handler entirely: no handler of ours runs, so no envelope exists and the
|
|
@@ -116,7 +116,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
|
|
|
116
116
|
return { action: 'forward', msg };
|
|
117
117
|
const all = [...screened.unknown.map((u) => u.sent), ...screened.redundant];
|
|
118
118
|
// Key names only; argument VALUES never reach the log.
|
|
119
|
-
log(`[args] ${tool}: undeclared ${all
|
|
119
|
+
log(`[args] ${tool}: undeclared ${echoNames(all)}${opts.mode === 'warn' ? ' (dropped)' : ' (rejected)'}`);
|
|
120
120
|
try {
|
|
121
121
|
// Only ever a DECLARED key or the literal placeholder, so the metrics
|
|
122
122
|
// closed-vocabulary rule holds: the caller's key is never persisted.
|
|
@@ -132,7 +132,7 @@ export function screenMessage(msg, opts, log = (l) => process.stderr.write(`${l}
|
|
|
132
132
|
const siblings = screened.unknown.some((u) => u.suggestions.length > 0)
|
|
133
133
|
? []
|
|
134
134
|
: (opts.siblingsFor?.(tool, screened.unknown.map((u) => u.sent)) ?? []);
|
|
135
|
-
const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? account : undefined, siblings);
|
|
135
|
+
const envelope = unknownArgEnvelope(tool, screened.unknown, declared, typeof account === 'string' ? echoName(account) : undefined, siblings);
|
|
136
136
|
return {
|
|
137
137
|
action: 'reject',
|
|
138
138
|
response: {
|
package/dist/arg-strict.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export declare const STRICT_EXEMPT_TOOLS: ReadonlySet<string>;
|
|
|
14
14
|
* tools: no declared key starts with `_` or contains `/`, so neither rule can
|
|
15
15
|
* shadow a real parameter. */
|
|
16
16
|
export declare function isExemptKey(key: string): boolean;
|
|
17
|
+
/** A call can carry thousands of unknown keys, or one of megabytes: only the
|
|
18
|
+
* first few earn a did-you-mean, and the log line and the envelope echo a
|
|
19
|
+
* bounded prefix of each name. The reject decision itself is never capped. */
|
|
20
|
+
export declare const MAX_SUGGESTED_KEYS = 8;
|
|
21
|
+
export declare function echoName(name: string): string;
|
|
22
|
+
/** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
|
|
23
|
+
export declare function echoNames(names: readonly string[], quote?: boolean): string;
|
|
17
24
|
/**
|
|
18
25
|
* Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
|
|
19
26
|
* 0 same key modulo case and separators
|
package/dist/arg-strict.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// and never touches a VALUE: it only decides that a key is undeclared, and
|
|
10
10
|
// suggests what the caller probably meant. Rewriting would risk sending a real
|
|
11
11
|
// value to Google on a guess, which is strictly worse than refusing.
|
|
12
|
-
import { editDistance } from './scope-catalog.js';
|
|
12
|
+
import { editDistance, SUGGEST_MAX_INPUT } from './scope-catalog.js';
|
|
13
|
+
import { sliceClean } from './trim.js';
|
|
13
14
|
/**
|
|
14
15
|
* `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
|
|
15
16
|
* back to `warn`, not to the default: an operator who misspells the setting
|
|
@@ -59,6 +60,20 @@ export function isExemptKey(key) {
|
|
|
59
60
|
* alone; they match far too much to be useful. */
|
|
60
61
|
const GENERIC = new Set(['id', 'name', 'title', 'body', 'parent', 'text', 'content', 'type', 'value', 'key', 'data']);
|
|
61
62
|
const flatten = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
63
|
+
/** A call can carry thousands of unknown keys, or one of megabytes: only the
|
|
64
|
+
* first few earn a did-you-mean, and the log line and the envelope echo a
|
|
65
|
+
* bounded prefix of each name. The reject decision itself is never capped. */
|
|
66
|
+
export const MAX_SUGGESTED_KEYS = 8;
|
|
67
|
+
const MAX_ECHO_CHARS = 64;
|
|
68
|
+
export function echoName(name) {
|
|
69
|
+
return name.length > MAX_ECHO_CHARS ? `${sliceClean(name, MAX_ECHO_CHARS)}...` : name;
|
|
70
|
+
}
|
|
71
|
+
/** The first MAX_SUGGESTED_KEYS names, each capped, then "and N more". */
|
|
72
|
+
export function echoNames(names, quote = false) {
|
|
73
|
+
const shown = names.slice(0, MAX_SUGGESTED_KEYS).map((n) => (quote ? `"${echoName(n)}"` : echoName(n)));
|
|
74
|
+
const rest = names.length - shown.length;
|
|
75
|
+
return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ');
|
|
76
|
+
}
|
|
62
77
|
function tokens(s) {
|
|
63
78
|
return s
|
|
64
79
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
@@ -76,6 +91,8 @@ function tokens(s) {
|
|
|
76
91
|
* distance 6, well past any sane threshold, which is why tier 1 exists.
|
|
77
92
|
*/
|
|
78
93
|
export function suggestKeys(unknown, declared, limit = 2) {
|
|
94
|
+
if (unknown.length > SUGGEST_MAX_INPUT)
|
|
95
|
+
return [];
|
|
79
96
|
const uFlat = flatten(unknown);
|
|
80
97
|
const uTok = tokens(unknown);
|
|
81
98
|
const tiers = [[], [], [], []];
|
|
@@ -94,8 +111,12 @@ export function suggestKeys(unknown, declared, limit = 2) {
|
|
|
94
111
|
tiers[2].push({ key, rank: Math.abs(kFlat.length - uFlat.length) });
|
|
95
112
|
continue;
|
|
96
113
|
}
|
|
114
|
+
const maxDist = Math.max(1, Math.floor(uFlat.length / 4));
|
|
115
|
+
// The distance is at least the length gap: skip the DP when that alone fails.
|
|
116
|
+
if (Math.abs(uFlat.length - kFlat.length) > maxDist)
|
|
117
|
+
continue;
|
|
97
118
|
const d = editDistance(uFlat, kFlat);
|
|
98
|
-
if (d <=
|
|
119
|
+
if (d <= maxDist)
|
|
99
120
|
tiers[3].push({ key, rank: d });
|
|
100
121
|
}
|
|
101
122
|
const tier = tiers.find((t) => t.length > 0);
|
|
@@ -117,10 +138,19 @@ export function screenArguments(tool, args, declared) {
|
|
|
117
138
|
if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
|
|
118
139
|
return out;
|
|
119
140
|
const declaredSet = new Set(declared);
|
|
141
|
+
// Tier 0 (same key modulo case and separators) is a map lookup, so every key
|
|
142
|
+
// gets it and a redundant twin is recognised past the ranking budget too.
|
|
143
|
+
// A near-miss duplicate (a typo sent next to its declared key) is recognised
|
|
144
|
+
// only within the budget: a call needs 9 of them before one is refused.
|
|
145
|
+
const byFlat = new Map();
|
|
146
|
+
for (const k of declared)
|
|
147
|
+
byFlat.set(flatten(k), [...(byFlat.get(flatten(k)) ?? []), k]);
|
|
148
|
+
let ranked = 0;
|
|
120
149
|
for (const key of Object.keys(args)) {
|
|
121
150
|
if (declaredSet.has(key) || isExemptKey(key))
|
|
122
151
|
continue;
|
|
123
|
-
const
|
|
152
|
+
const twins = key.length <= SUGGEST_MAX_INPUT ? byFlat.get(flatten(key)) : undefined;
|
|
153
|
+
const suggestions = twins ? twins.slice(0, 2) : ranked++ < MAX_SUGGESTED_KEYS ? suggestKeys(key, declared) : [];
|
|
124
154
|
// The caller sent both spellings, so the declared one already won and the
|
|
125
155
|
// outcome is what it has always been. Never fail a call that works today.
|
|
126
156
|
if (suggestions.some((s) => s in args)) {
|
|
@@ -133,7 +163,7 @@ export function screenArguments(tool, args, declared) {
|
|
|
133
163
|
}
|
|
134
164
|
/** The agent-facing envelope. Values are never included, only key names. */
|
|
135
165
|
export function unknownArgEnvelope(tool, unknown, declared, account, siblings = []) {
|
|
136
|
-
const names = unknown.map((u) =>
|
|
166
|
+
const names = echoNames(unknown.map((u) => u.sent), true);
|
|
137
167
|
const accepts = `This tool accepts: ${declared.join(', ')}.`;
|
|
138
168
|
const suggested = unknown.flatMap((u) => u.suggestions);
|
|
139
169
|
let hint;
|
package/dist/compose.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// parsing, no process.exit, no prints (accounts.js module-load resolution is
|
|
5
5
|
// the one inherited, pre-existing exception on the import chain).
|
|
6
6
|
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
7
|
-
import { GENERATED_GATES, SERVICES, unknownToolMessage } from './services.js';
|
|
7
|
+
import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
|
|
8
8
|
import { argNormalizationEnabled } from './arg-normalize.js';
|
|
9
9
|
import { unknownArgMode } from './arg-strict.js';
|
|
10
10
|
import { ToolRegistry } from './registry.js';
|
|
@@ -81,7 +81,7 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
|
|
|
81
81
|
// The wizard edits the owner's config.json and token store; any other
|
|
82
82
|
// context has neither.
|
|
83
83
|
if (owner)
|
|
84
|
-
registerAccountWizardTools(registry, server);
|
|
84
|
+
registerAccountWizardTools(registry, server, () => servicesAwaitingRestart(registry));
|
|
85
85
|
return registry;
|
|
86
86
|
}
|
|
87
87
|
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
@@ -49,5 +49,8 @@ export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Prom
|
|
|
49
49
|
export declare function clearDiscoveryMemoryCache(): void;
|
|
50
50
|
export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
|
|
51
51
|
export declare function expandPath(template: string, pathParams: Record<string, string>): string;
|
|
52
|
+
/** Search keywords are a few words; the caps keep a caller-sized query from
|
|
53
|
+
* driving index-size x token-count substring scans on the event loop. */
|
|
54
|
+
export declare const MAX_SEARCH_QUERY = 500;
|
|
52
55
|
export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
|
|
53
56
|
export {};
|
package/dist/discovery-client.js
CHANGED
|
@@ -263,8 +263,13 @@ export function expandPath(template, pathParams) {
|
|
|
263
263
|
return segments.map(encodeURIComponent).join('/');
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
|
+
/** Search keywords are a few words; the caps keep a caller-sized query from
|
|
267
|
+
* driving index-size x token-count substring scans on the event loop. */
|
|
268
|
+
export const MAX_SEARCH_QUERY = 500;
|
|
269
|
+
const MAX_SEARCH_TOKENS = 16;
|
|
266
270
|
export function searchMethods(index, query, limit = 10) {
|
|
267
|
-
const
|
|
271
|
+
const words = query.slice(0, MAX_SEARCH_QUERY).toLowerCase().split(/\s+/).filter(Boolean);
|
|
272
|
+
const tokens = [...new Set(words)].slice(0, MAX_SEARCH_TOKENS);
|
|
268
273
|
if (tokens.length === 0)
|
|
269
274
|
return index.slice(0, limit);
|
|
270
275
|
const scored = index
|
package/dist/fanout.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export declare const CSV_RE: RegExp;
|
|
3
|
-
export declare function fanoutAccountField(description: string, aliases: readonly string[]): z.ZodType;
|
|
3
|
+
export declare function fanoutAccountField(description: string, aliases: () => readonly string[]): z.ZodType;
|
|
4
|
+
/** '*' over an empty account set would fan out to nothing and report success. */
|
|
5
|
+
export declare function noAccountsResult(): {
|
|
6
|
+
content: {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
}[];
|
|
10
|
+
isError: true;
|
|
11
|
+
};
|
|
4
12
|
export type AccountSelector = {
|
|
5
13
|
ok: true;
|
|
6
14
|
fanout: boolean;
|
package/dist/fanout.js
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { getAccountSet, unknownAliasMessage } from './accounts.js';
|
|
2
|
+
import { getAccountSet, liveAccountCheck, unknownAliasMessage } from './accounts.js';
|
|
3
|
+
import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
|
|
3
4
|
export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
|
|
4
5
|
const FANOUT_CONCURRENCY = 5;
|
|
5
6
|
// `aliases` is required (no global default): the field is baked into a
|
|
6
|
-
// registry's inputSchema, so it must
|
|
7
|
-
//
|
|
7
|
+
// registry's inputSchema, so it must read THAT registry's account set or a
|
|
8
|
+
// '*' would advertise and accept another registry's aliases. It is a getter:
|
|
9
|
+
// the set is read at parse time, so an alias added mid-session (account_add,
|
|
10
|
+
// a tenant link) is valid on tools registered before it existed.
|
|
8
11
|
export function fanoutAccountField(description, aliases) {
|
|
9
|
-
const
|
|
12
|
+
const boot = aliases();
|
|
13
|
+
const csvExample = boot.length > 1 ? `; or a CSV subset like "${boot.slice(0, 2).join(',')}"` : '';
|
|
10
14
|
// The union's OWN error only fires when every branch aborts at the type
|
|
11
15
|
// check (a non-string). For a string, zod surfaces the single non-aborted
|
|
12
16
|
// branch verbatim, which is the CSV regex, so a mistyped alias used to read
|
|
13
17
|
// as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
|
|
14
18
|
// named an alias. Hence the same message on BOTH.
|
|
15
|
-
const bad = () => unknownAliasMessage(aliases, true);
|
|
19
|
+
const bad = () => unknownAliasMessage(aliases(), true);
|
|
16
20
|
return z
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
.union([z.
|
|
21
|
+
// The selector branch stays first: tools/list injects the live
|
|
22
|
+
// '*' + alias enum into it (registry withLiveAccountEnum).
|
|
23
|
+
.union([z.string().check(liveAccountCheck(aliases, true)), z.string().regex(CSV_RE, { error: bad })], { error: bad })
|
|
20
24
|
.optional()
|
|
21
25
|
.describe(`${description}; "*" = all accounts${csvExample}; omit for the default account`);
|
|
22
26
|
}
|
|
27
|
+
/** '*' over an empty account set would fan out to nothing and report success. */
|
|
28
|
+
export function noAccountsResult() {
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: 'text',
|
|
33
|
+
text: JSON.stringify({
|
|
34
|
+
error: 'validation_error',
|
|
35
|
+
message: 'No Google account is configured, so "*" selects nothing.',
|
|
36
|
+
hint: unknownAliasMessage([]),
|
|
37
|
+
retriable: false,
|
|
38
|
+
account: '*',
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
isError: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
23
45
|
export function parseAccountSelector(value, accounts = getAccountSet().aliases) {
|
|
24
46
|
if (value === '*')
|
|
25
47
|
return { ok: true, fanout: true, aliases: [...accounts] };
|
|
@@ -56,10 +78,11 @@ export function invalidAccountsResult(invalid, accounts = getAccountSet().aliase
|
|
|
56
78
|
type: 'text',
|
|
57
79
|
text: JSON.stringify({
|
|
58
80
|
error: 'validation_error',
|
|
59
|
-
|
|
81
|
+
// The caller's CSV can hold any number of tokens: echo a prefix.
|
|
82
|
+
message: `Unknown account alias(es): ${echoNames(invalid)}.`,
|
|
60
83
|
hint: unknownAliasMessage(accounts, true),
|
|
61
84
|
retriable: false,
|
|
62
|
-
account: invalid.join(','),
|
|
85
|
+
account: invalid.slice(0, MAX_SUGGESTED_KEYS).map(echoName).join(','),
|
|
63
86
|
}),
|
|
64
87
|
},
|
|
65
88
|
],
|
package/dist/http-transport.js
CHANGED
|
@@ -9,6 +9,10 @@ import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js
|
|
|
9
9
|
// serialize() lock forever. Generous by default so slow-but-valid calls (large
|
|
10
10
|
// Drive exports, fan-out) still finish; the point is only to guarantee release.
|
|
11
11
|
const DISPATCH_TIMEOUT_DEFAULT = 120_000;
|
|
12
|
+
/** MCP 2025-06-18 dropped JSON-RPC batching, and the SDK screens every frame
|
|
13
|
+
* of a batch synchronously in one tick: without a cap one body multiplies the
|
|
14
|
+
* per-call work by however many frames fit in it. */
|
|
15
|
+
const MAX_BATCH_FRAMES = 16;
|
|
12
16
|
export function parseOwnerEmails(env = process.env) {
|
|
13
17
|
return (env.MCP_OWNER_EMAILS ?? '')
|
|
14
18
|
.split(',')
|
|
@@ -198,6 +202,9 @@ export class HttpTransportHost {
|
|
|
198
202
|
catch (e) {
|
|
199
203
|
return this.fail(res, 400, 'invalid_body', e.message);
|
|
200
204
|
}
|
|
205
|
+
if (Array.isArray(body) && body.length > MAX_BATCH_FRAMES) {
|
|
206
|
+
return this.fail(res, 400, 'batch_too_large', `a JSON-RPC batch may hold at most ${MAX_BATCH_FRAMES} messages`);
|
|
207
|
+
}
|
|
201
208
|
// Host/Origin are enforced by the front guard above (uniformly for /mcp and
|
|
202
209
|
// the mounted AS routes), so the SDK's own DNS-rebind guard is disabled: its
|
|
203
210
|
// exact-Host match is stricter than the front guard and would 403 valid
|
package/dist/index.js
CHANGED
|
@@ -252,6 +252,7 @@ async function main() {
|
|
|
252
252
|
const { OAuth2Client } = await import('googleapis-common');
|
|
253
253
|
const { writeToken } = await import('./token-store.js');
|
|
254
254
|
const { resolveScopesForAccount } = await import('./auth.js');
|
|
255
|
+
const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
|
|
255
256
|
const { configDir } = await import('./config-file.js');
|
|
256
257
|
const { getAccountSet } = await import('./accounts.js');
|
|
257
258
|
const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
|
|
@@ -265,20 +266,7 @@ async function main() {
|
|
|
265
266
|
masterKey: resolveMasterKeyForDispatch().key,
|
|
266
267
|
refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
|
|
267
268
|
}, {
|
|
268
|
-
buildGoogleAuthUrl: ({ flow, alias, state }) => {
|
|
269
|
-
if (flow === 'alias_reauth' && alias) {
|
|
270
|
-
const cfg = getAccountSet().configs[alias];
|
|
271
|
-
// openid+email so /callback can bind the returned identity to the alias.
|
|
272
|
-
return googleClient().generateAuthUrl({
|
|
273
|
-
access_type: 'offline',
|
|
274
|
-
prompt: 'consent',
|
|
275
|
-
scope: [...new Set([...resolveScopesForAccount(alias), 'openid', 'email'])],
|
|
276
|
-
login_hint: cfg?.email,
|
|
277
|
-
state,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
return googleClient().generateAuthUrl({ scope: ['openid', 'email'], prompt: 'select_account', state });
|
|
281
|
-
},
|
|
269
|
+
buildGoogleAuthUrl: ({ flow, alias, state }) => googleClient().generateAuthUrl(ownerAuthUrlOptions({ flow, alias, state }, (a) => resolveScopesForAccount(a))),
|
|
282
270
|
exchangeCode: async (code, flow) => {
|
|
283
271
|
const { tokens } = await googleClient().getToken(code);
|
|
284
272
|
const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -48,9 +48,9 @@ export interface AuthServerDeps {
|
|
|
48
48
|
/** Exchange a Google auth code at `${base}/callback` for tokens (+ owner email). */
|
|
49
49
|
exchangeCode?: (code: string, flow: StatePayload['flow']) => Promise<GoogleExchangeResult>;
|
|
50
50
|
/** Build the Google authorization URL (redirect back to `${base}/callback`).
|
|
51
|
-
* The impl chooses scope/prompt
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* The impl chooses scope/prompt from the flow: owner_gate = `openid email`
|
|
52
|
+
* + select_account; alias_reauth = the alias's resource scopes + select_account
|
|
53
|
+
* consent, and no login_hint (the link needs no authentication). */
|
|
54
54
|
buildGoogleAuthUrl?: (opts: {
|
|
55
55
|
flow: StatePayload['flow'];
|
|
56
56
|
alias?: string;
|
package/dist/oauth-as.js
CHANGED
|
@@ -407,6 +407,14 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
407
407
|
log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
|
|
408
408
|
return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
|
|
409
409
|
}
|
|
410
|
+
// A completion without a refresh token (a Google URL built without
|
|
411
|
+
// offline access, say) would replace a working credential with one that
|
|
412
|
+
// dies within the hour. Keep the stored token instead.
|
|
413
|
+
const refreshToken = exchanged.tokens.refresh_token;
|
|
414
|
+
if (typeof refreshToken !== 'string' || refreshToken === '') {
|
|
415
|
+
log(`alias_reauth for "${st.alias}" returned no refresh token; stored token kept`);
|
|
416
|
+
return errorPage(res, 400, 'E_REAUTH_INCOMPLETE', 'Google did not return a long-lived token, so the stored one was kept. Open the re-auth link again and approve access.');
|
|
417
|
+
}
|
|
410
418
|
deps.writeToken?.(st.alias, exchanged.tokens);
|
|
411
419
|
log(`callback ok flow=alias_reauth alias=${st.alias}`);
|
|
412
420
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
+
/** Google authorize-URL options for the owner's HTTP authorization server.
|
|
3
|
+
* alias_reauth asks for the alias's scopes plus openid/email, which /callback
|
|
4
|
+
* needs to bind the returned identity to the alias. It carries no login_hint:
|
|
5
|
+
* the link works without authentication, so a hint would hand the alias's
|
|
6
|
+
* address to anyone who opens it. Every other flow is identity-only. */
|
|
7
|
+
export declare function ownerAuthUrlOptions(opts: {
|
|
8
|
+
flow: string;
|
|
9
|
+
alias?: string;
|
|
10
|
+
state: string;
|
|
11
|
+
}, scopesFor: (alias: string) => string[]): Parameters<OAuth2Client['generateAuthUrl']>[0];
|
|
2
12
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
3
13
|
export declare class ClientCredentialsMissingError extends Error {
|
|
4
14
|
constructor();
|
package/dist/oauth-consent.js
CHANGED
|
@@ -7,6 +7,22 @@ import { URL } from 'node:url';
|
|
|
7
7
|
// call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
|
|
8
8
|
// Google Desktop clients accept any http://localhost:<port> redirect), so a
|
|
9
9
|
// second local process on a fixed port can never break auth.
|
|
10
|
+
/** Google authorize-URL options for the owner's HTTP authorization server.
|
|
11
|
+
* alias_reauth asks for the alias's scopes plus openid/email, which /callback
|
|
12
|
+
* needs to bind the returned identity to the alias. It carries no login_hint:
|
|
13
|
+
* the link works without authentication, so a hint would hand the alias's
|
|
14
|
+
* address to anyone who opens it. Every other flow is identity-only. */
|
|
15
|
+
export function ownerAuthUrlOptions(opts, scopesFor) {
|
|
16
|
+
if (opts.flow === 'alias_reauth' && opts.alias) {
|
|
17
|
+
return {
|
|
18
|
+
access_type: 'offline',
|
|
19
|
+
prompt: 'select_account consent',
|
|
20
|
+
scope: [...new Set([...scopesFor(opts.alias), 'openid', 'email'])],
|
|
21
|
+
state: opts.state,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { scope: ['openid', 'email'], prompt: 'select_account', state: opts.state };
|
|
25
|
+
}
|
|
10
26
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
11
27
|
export class ClientCredentialsMissingError extends Error {
|
|
12
28
|
constructor() {
|
package/dist/registry.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface CatalogOperation {
|
|
|
38
38
|
* real scope error rather than a bare "not found". */
|
|
39
39
|
unreachable?: true;
|
|
40
40
|
}
|
|
41
|
+
/** The service a tool name belongs to: its prefix, unless overridden. */
|
|
42
|
+
export declare function serviceOf(name: string): string;
|
|
41
43
|
export declare function inferCud(name: string): Cud;
|
|
42
44
|
export declare class ToolRegistry {
|
|
43
45
|
private readonly server;
|
package/dist/registry.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-control.js';
|
|
3
|
-
import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
|
|
3
|
+
import { getAccountSet, isLiveAccountField, refreshAccountSetIfStale } from './accounts.js';
|
|
4
4
|
import { compactResult, trimEnabled } from './trim.js';
|
|
5
|
-
import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
|
|
5
|
+
import { fanoutAccountField, invalidAccountsResult, noAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
|
|
6
6
|
import { MAX_RESPONSE_CHARS } from './executor.js';
|
|
7
|
-
import { suggestKeys } from './arg-strict.js';
|
|
7
|
+
import { MAX_SUGGESTED_KEYS, suggestKeys } from './arg-strict.js';
|
|
8
8
|
import { classifyScope } from './scope-observability.js';
|
|
9
9
|
/** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
|
|
10
10
|
* reachable at all, independently of any account. */
|
|
@@ -40,6 +40,10 @@ const CUD_OVERRIDES = {
|
|
|
40
40
|
const SERVICE_OVERRIDES = {
|
|
41
41
|
reports_activities_list: 'admin',
|
|
42
42
|
};
|
|
43
|
+
/** The service a tool name belongs to: its prefix, unless overridden. */
|
|
44
|
+
export function serviceOf(name) {
|
|
45
|
+
return Object.hasOwn(SERVICE_OVERRIDES, name) ? SERVICE_OVERRIDES[name] : name.includes('_') ? name.slice(0, name.indexOf('_')) : name;
|
|
46
|
+
}
|
|
43
47
|
// read tools that write local files — same savePath fanned across accounts would clobber
|
|
44
48
|
const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
|
|
45
49
|
/** Account-management tools: the `account` they take is the SUBJECT of the
|
|
@@ -64,7 +68,11 @@ function scalarKindOf(field) {
|
|
|
64
68
|
}
|
|
65
69
|
return 'other';
|
|
66
70
|
}
|
|
67
|
-
|
|
71
|
+
/** A field naming one of the registry's accounts: a baked enum or a live
|
|
72
|
+
* account check (curated tools and generated tools both validate live). */
|
|
73
|
+
function isAccountSelector(field) {
|
|
74
|
+
if (isLiveAccountField(field))
|
|
75
|
+
return true;
|
|
68
76
|
const def = field?._zod?.def;
|
|
69
77
|
if (!def)
|
|
70
78
|
return false;
|
|
@@ -119,7 +127,7 @@ export class ToolRegistry {
|
|
|
119
127
|
this.policy = policy;
|
|
120
128
|
this.mode = mode;
|
|
121
129
|
this.registerTool = ((name, config, handler) => {
|
|
122
|
-
const service =
|
|
130
|
+
const service = serviceOf(name);
|
|
123
131
|
const cud = config.cud ?? inferCud(name);
|
|
124
132
|
// destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
|
|
125
133
|
// idempotent: reads trivially, deletes (already-gone = same), updates
|
|
@@ -146,14 +154,16 @@ export class ToolRegistry {
|
|
|
146
154
|
let inputShape = config.inputSchema ?? {};
|
|
147
155
|
let baseHandler = handler;
|
|
148
156
|
const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
|
|
149
|
-
if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) &&
|
|
157
|
+
if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountSelector(inputShape.account)) {
|
|
150
158
|
const description = inputShape.account.description ?? 'Google account alias';
|
|
151
|
-
inputShape = { ...inputShape, account: fanoutAccountField(description, this.accounts().aliases) };
|
|
159
|
+
inputShape = { ...inputShape, account: fanoutAccountField(description, () => this.accounts().aliases) };
|
|
152
160
|
baseHandler = async (...args) => {
|
|
153
161
|
const first = args[0];
|
|
154
162
|
const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '', this.accounts().aliases);
|
|
155
163
|
if (!parsed.ok)
|
|
156
164
|
return invalidAccountsResult(parsed.invalid, this.accounts().aliases);
|
|
165
|
+
if (parsed.aliases.length === 0)
|
|
166
|
+
return noAccountsResult();
|
|
157
167
|
if (!parsed.fanout)
|
|
158
168
|
return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
|
|
159
169
|
return runFanout(handler, args, parsed.aliases);
|
|
@@ -309,7 +319,7 @@ export class ToolRegistry {
|
|
|
309
319
|
// case (parentId against parentFolderId) fails containment and edit
|
|
310
320
|
// distance alike, which is the whole reason that matcher exists.
|
|
311
321
|
const keys = candidates.map(([key]) => key);
|
|
312
|
-
const hits = new Set(unknownKeys.flatMap((k) => suggestKeys(k, keys)));
|
|
322
|
+
const hits = new Set(unknownKeys.slice(0, MAX_SUGGESTED_KEYS).flatMap((k) => suggestKeys(k, keys)));
|
|
313
323
|
return candidates
|
|
314
324
|
.filter(([key]) => hits.has(key))
|
|
315
325
|
.slice(0, 2)
|
|
@@ -478,11 +488,12 @@ export class ToolRegistry {
|
|
|
478
488
|
if (aliases.length === 0)
|
|
479
489
|
return schema;
|
|
480
490
|
if (Array.isArray(account.anyOf)) {
|
|
481
|
-
// fan-out union:
|
|
482
|
-
|
|
483
|
-
|
|
491
|
+
// fan-out union: advertise '*' + the live aliases on the selector branch
|
|
492
|
+
// (the one without the CSV pattern), keep the CSV branch as is
|
|
493
|
+
const isSelector = (b) => b.pattern === undefined;
|
|
494
|
+
if (!account.anyOf.some(isSelector))
|
|
484
495
|
return schema;
|
|
485
|
-
const anyOf = account.anyOf.map((b) => (
|
|
496
|
+
const anyOf = account.anyOf.map((b) => (isSelector(b) ? { ...b, enum: ['*', ...aliases] } : b));
|
|
486
497
|
return { ...s, properties: { ...s.properties, account: { ...account, anyOf } } };
|
|
487
498
|
}
|
|
488
499
|
return { ...s, properties: { ...s.properties, account: { ...account, enum: [...aliases] } } };
|
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -20,4 +20,12 @@ export declare function isKnownBundle(name: string): boolean;
|
|
|
20
20
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
21
21
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
22
22
|
export declare function closestBundle(name: string): string | undefined;
|
|
23
|
+
/** Longest input any did-you-mean compares. Every name it suggests (tool,
|
|
24
|
+
* argument key, bundle, Discovery method id) is far shorter, and the inputs
|
|
25
|
+
* are caller-controlled: an unbounded comparison let one oversized name block
|
|
26
|
+
* the event loop for minutes. */
|
|
27
|
+
export declare const SUGGEST_MAX_INPUT = 128;
|
|
28
|
+
/** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
|
|
29
|
+
* without comparing: an upper bound on the true distance, and above every
|
|
30
|
+
* caller's accept threshold, so a capped pair is never suggested. */
|
|
23
31
|
export declare function editDistance(a: string, b: string): number;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -182,6 +182,8 @@ export function closestBundle(name) {
|
|
|
182
182
|
let best;
|
|
183
183
|
let bestDist = 3;
|
|
184
184
|
for (const key of Object.keys(BUNDLE_CATALOG)) {
|
|
185
|
+
if (Math.abs(name.length - key.length) >= bestDist)
|
|
186
|
+
continue;
|
|
185
187
|
const d = editDistance(name.toLowerCase(), key);
|
|
186
188
|
if (d < bestDist) {
|
|
187
189
|
bestDist = d;
|
|
@@ -190,14 +192,24 @@ export function closestBundle(name) {
|
|
|
190
192
|
}
|
|
191
193
|
return best;
|
|
192
194
|
}
|
|
195
|
+
/** Longest input any did-you-mean compares. Every name it suggests (tool,
|
|
196
|
+
* argument key, bundle, Discovery method id) is far shorter, and the inputs
|
|
197
|
+
* are caller-controlled: an unbounded comparison let one oversized name block
|
|
198
|
+
* the event loop for minutes. */
|
|
199
|
+
export const SUGGEST_MAX_INPUT = 128;
|
|
200
|
+
/** Levenshtein distance. Past SUGGEST_MAX_INPUT it returns the longer length
|
|
201
|
+
* without comparing: an upper bound on the true distance, and above every
|
|
202
|
+
* caller's accept threshold, so a capped pair is never suggested. */
|
|
193
203
|
export function editDistance(a, b) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
if (a.length > SUGGEST_MAX_INPUT || b.length > SUGGEST_MAX_INPUT)
|
|
205
|
+
return Math.max(a.length, b.length);
|
|
206
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
197
207
|
for (let i = 1; i <= a.length; i++) {
|
|
208
|
+
const row = [i];
|
|
198
209
|
for (let j = 1; j <= b.length; j++) {
|
|
199
|
-
|
|
210
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
200
211
|
}
|
|
212
|
+
prev = row;
|
|
201
213
|
}
|
|
202
|
-
return
|
|
214
|
+
return prev[b.length];
|
|
203
215
|
}
|
package/dist/services.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AccountSet } from './accounts.js';
|
|
2
2
|
import type { CuratedToolDeps } from './client.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type ToolRegistry } from './registry.js';
|
|
4
4
|
export interface ServiceEntry {
|
|
5
5
|
name: string;
|
|
6
6
|
register: (registry: ToolRegistry, deps?: CuratedToolDeps) => void;
|
|
@@ -14,11 +14,11 @@ export declare const GENERATED_GATES: Record<string, {
|
|
|
14
14
|
enabled: (set?: AccountSet) => boolean;
|
|
15
15
|
hint: string;
|
|
16
16
|
}>;
|
|
17
|
+
export declare function unknownToolMessage(registry: ToolRegistry, rawName: string): string;
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* is which: it composed the enabled-service list at boot.
|
|
19
|
+
* Gated services the registry's CURRENT accounts enable but that registered
|
|
20
|
+
* nothing: an account added mid-session unlocked them after buildRegistry
|
|
21
|
+
* decided the gates. Mirrors compose.ts gate for gate, so an ungated or
|
|
22
|
+
* toolset-excluded service is never reported.
|
|
23
23
|
*/
|
|
24
|
-
export declare function
|
|
24
|
+
export declare function servicesAwaitingRestart(registry: ToolRegistry): string[];
|