mcp-google-multi 6.0.0-beta.3 → 6.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/accounts.d.ts +3 -3
- package/dist/accounts.js +18 -31
- package/dist/arg-normalize.js +15 -35
- package/dist/arg-strict.js +8 -28
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +5 -10
- package/dist/boot-gates.d.ts +1 -1
- package/dist/boot-gates.js +3 -5
- package/dist/cimd-cache.js +5 -11
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +8 -1
- package/dist/compose.js +11 -11
- package/dist/config-file.js +7 -12
- package/dist/discover.js +4 -9
- package/dist/discovery-client.js +14 -33
- package/dist/doctor.d.ts +3 -3
- package/dist/doctor.js +12 -18
- package/dist/env-load.js +2 -5
- package/dist/executor.js +2 -4
- package/dist/fanout.js +8 -17
- package/dist/fs-atomic.d.ts +0 -3
- package/dist/fs-atomic.js +6 -10
- package/dist/hint-provider.js +4 -10
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +5 -5
- package/dist/http-transport.js +39 -92
- package/dist/identity.d.ts +2 -2
- package/dist/identity.js +6 -9
- package/dist/index.js +19 -31
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +11 -22
- package/dist/mcp-token.d.ts +3 -13
- package/dist/mcp-token.js +16 -32
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +3 -5
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +2 -2
- package/dist/oauth-as.js +32 -73
- package/dist/oauth-consent.js +4 -10
- package/dist/outbound-allowlist.js +4 -9
- package/dist/reauth-hint.js +2 -5
- package/dist/registry-transfer.d.ts +1 -1
- package/dist/registry-transfer.js +5 -13
- package/dist/registry.d.ts +1 -9
- package/dist/registry.js +38 -66
- package/dist/scope-catalog.d.ts +5 -0
- package/dist/scope-catalog.js +17 -7
- package/dist/scope-observability.d.ts +2 -3
- package/dist/scope-observability.js +8 -14
- package/dist/services.d.ts +3 -0
- package/dist/services.js +5 -10
- package/dist/ssrf-guard.js +11 -22
- package/dist/tenant-purge.js +2 -4
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.js +22 -39
- package/dist/tools/_local-files.js +4 -8
- package/dist/tools/account-wizard.d.ts +4 -3
- package/dist/tools/account-wizard.js +48 -54
- package/dist/tools/accounts-tool.d.ts +1 -1
- package/dist/tools/accounts-tool.js +1 -1
- package/dist/tools/admin.js +2 -3
- package/dist/tools/analytics.js +4 -5
- package/dist/tools/calendar.js +4 -7
- package/dist/tools/chat.js +2 -3
- package/dist/tools/contacts.js +8 -9
- package/dist/tools/docs.js +4 -9
- package/dist/tools/drive.js +18 -27
- package/dist/tools/forms.js +2 -3
- package/dist/tools/gmail-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +2 -2
- package/dist/tools/gmail.js +26 -40
- package/dist/tools/google-api.js +5 -7
- package/dist/tools/meet.js +2 -3
- package/dist/tools/searchconsole.js +2 -3
- package/dist/tools/sheets.js +2 -3
- package/dist/tools/slides.js +2 -3
- package/dist/tools/tasks.js +2 -3
- package/dist/trim.js +2 -3
- package/dist/usage-metrics.js +5 -12
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +3 -4
- package/package.json +21 -21
package/dist/tools/_errors.js
CHANGED
|
@@ -25,10 +25,8 @@ function headerOf(error, name) {
|
|
|
25
25
|
const key = Object.keys(h).find((k) => k.toLowerCase() === name);
|
|
26
26
|
return key === undefined ? undefined : String(h[key]);
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* drive_export), where it buffers the whole body to utf8 before throwing. So
|
|
31
|
-
* the JSON error of a failed download is invisible to a plain property read. */
|
|
28
|
+
// gaxios hands response.data back as a STRING whenever it can't parse JSON,
|
|
29
|
+
// including every non-2xx of a responseType:'stream' call (drive_download/export).
|
|
32
30
|
function errorPayload(error) {
|
|
33
31
|
const data = error?.response?.data;
|
|
34
32
|
if (data && typeof data === 'object')
|
|
@@ -48,9 +46,8 @@ function errorPayload(error) {
|
|
|
48
46
|
return undefined;
|
|
49
47
|
}
|
|
50
48
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
* leave errors[0].reason empty, which is why their 403s used to fall through. */
|
|
49
|
+
// AIP-193 google.rpc.ErrorInfo: modern Workspace APIs put the machine-readable
|
|
50
|
+
// reason here and leave errors[0].reason empty, which is why their 403s used to fall through.
|
|
54
51
|
function errorInfoOf(payload) {
|
|
55
52
|
const details = payload?.error?.details;
|
|
56
53
|
if (!Array.isArray(details))
|
|
@@ -71,10 +68,8 @@ function reasonOf(error, payload) {
|
|
|
71
68
|
payload?.error?.status);
|
|
72
69
|
}
|
|
73
70
|
const MARKUP_START_RE = /^\uFEFF?\s*(?:<!--|<!\s*doctype\b|<\?xml\b|<\/?(?:html|head|body|meta|title|style|script|h[1-6]|div|p|pre|center|table|font)\b)/i;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
* NEVER matches page TEXT: the same URL answers "Page introuvable" to a
|
|
77
|
-
* French-locale account and "Page not found" to anonymous curl. */
|
|
71
|
+
// True when error.message is a response BODY, not an error sentence; never
|
|
72
|
+
// matches page TEXT (a localized "Page introuvable" must still pass through).
|
|
78
73
|
function isRawBody(error, message) {
|
|
79
74
|
const ct = headerOf(error, 'content-type')?.toLowerCase();
|
|
80
75
|
if (ct)
|
|
@@ -108,17 +103,15 @@ export function safeMessage(error, payload = errorPayload(error), status = statu
|
|
|
108
103
|
const raw = typeof error?.message === 'string' ? error.message : String(error);
|
|
109
104
|
return isRawBody(error, raw) ? bodyPlaceholder(error, status, raw.length) : capMessage(raw);
|
|
110
105
|
}
|
|
111
|
-
// Connect/DNS syscall codes
|
|
112
|
-
//
|
|
113
|
-
// with an empty message, so the code is the only surviving signal to surface.
|
|
106
|
+
// Connect/DNS syscall codes; ENOTFOUND is the one non-transient member. The
|
|
107
|
+
// code is the only surviving signal once node-fetch flattens the AggregateError.
|
|
114
108
|
const RETRIABLE_NET_CODES = new Set([
|
|
115
109
|
'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ENETUNREACH',
|
|
116
110
|
'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
|
|
117
111
|
]);
|
|
118
112
|
const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
|
|
119
|
-
// Local-filesystem syscall codes
|
|
120
|
-
//
|
|
121
|
-
// network codes above are deliberately excluded.
|
|
113
|
+
// Local-filesystem syscall codes (never collide with Google's numeric
|
|
114
|
+
// statuses); the network codes above are deliberately excluded.
|
|
122
115
|
const LOCAL_FS_CODES = new Set(['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR', 'EPERM', 'ELOOP', 'ENAMETOOLONG', 'ENOSPC']);
|
|
123
116
|
/** First known network code on the error or its cause chain (GaxiosError.cause
|
|
124
117
|
* -> FetchError; undici TypeError.cause -> AggregateError.errors). */
|
|
@@ -134,7 +127,7 @@ function netCodeOf(error) {
|
|
|
134
127
|
}
|
|
135
128
|
return undefined;
|
|
136
129
|
}
|
|
137
|
-
/** Console deep-link to enable one API (noob-proofing hint
|
|
130
|
+
/** Console deep-link to enable one API (noob-proofing hint). */
|
|
138
131
|
function apiEnableLink(api) {
|
|
139
132
|
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
140
133
|
}
|
|
@@ -156,8 +149,7 @@ const FORBIDDEN_RATE_REASONS = new Set([
|
|
|
156
149
|
'sharingRateLimitExceeded', 'quotaExceeded', 'RATE_LIMIT_EXCEEDED', 'RESOURCE_EXHAUSTED',
|
|
157
150
|
]);
|
|
158
151
|
// Google already answered correctly: the file type is wrong for the tool, not
|
|
159
|
-
// the permissions
|
|
160
|
-
// curated pair used to discard it and blame sharing instead.
|
|
152
|
+
// the permissions; the curated pair used to discard that and blame sharing instead.
|
|
161
153
|
const WRONG_TOOL_HINTS = {
|
|
162
154
|
fileNotDownloadable: 'This is a Google Workspace native file (Doc, Sheet, Slides): it has no binary content to download. Use drive_export with a target mimeType such as application/pdf.',
|
|
163
155
|
fileNotExportable: 'Export only works on Google Workspace native files. This one already has binary content: use drive_download to save it as is.',
|
|
@@ -194,9 +186,8 @@ function rateLimited(error, message, account, reason) {
|
|
|
194
186
|
account,
|
|
195
187
|
};
|
|
196
188
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
* only, unlike its localized HTML pages. */
|
|
189
|
+
// Prefer AIP metadata over scraping the message: the scrape stays as fallback
|
|
190
|
+
// since Google's JSON error text is English only, unlike its localized HTML pages.
|
|
200
191
|
function enableHint(info, message) {
|
|
201
192
|
const service = info?.metadata?.service;
|
|
202
193
|
const project = info?.metadata?.consumer?.replace(/^projects\//, '');
|
|
@@ -216,9 +207,7 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
|
|
|
216
207
|
const message = safeMessage(error, payload, status);
|
|
217
208
|
const hints = normalizeHints(serviceHints);
|
|
218
209
|
// 7-day Testing-mode trap: a BYO OAuth client in "Testing" status expires
|
|
219
|
-
// refresh tokens weekly; the
|
|
220
|
-
// real fix (publish to production) so the agent stops looping on re-auth.
|
|
221
|
-
// Checked before the generic 401 so it wins on either 400 or 401.
|
|
210
|
+
// refresh tokens weekly; checked before the generic 401 so it wins on either 400 or 401.
|
|
222
211
|
const rawError = payload?.error;
|
|
223
212
|
const grantHaystack = [message, reason, typeof rawError === 'string' ? rawError : '', payload?.error_description]
|
|
224
213
|
.filter(Boolean)
|
|
@@ -266,9 +255,8 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
|
|
|
266
255
|
});
|
|
267
256
|
return { error: 'api_not_enabled', message, hint: rendered?.hint ?? enableHint(info, message), retriable: false, account };
|
|
268
257
|
}
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// branching on it is what told callers to add a bundle they already had.
|
|
258
|
+
// Fires ONLY on the machine-readable token: a coarse PERMISSION_DENIED is
|
|
259
|
+
// equally true of a resource denial, and branching on it told callers to add a bundle they already had.
|
|
272
260
|
const scopeIssue = reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' ||
|
|
273
261
|
reason === 'insufficientPermissions' ||
|
|
274
262
|
/\bACCESS_TOKEN_SCOPE_INSUFFICIENT\b/.test(message);
|
|
@@ -334,9 +322,8 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
|
|
|
334
322
|
account,
|
|
335
323
|
};
|
|
336
324
|
}
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// retry" from "Google is having a bad minute, do retry".
|
|
325
|
+
// Used to share `upstream_error` with a 5xx, so a consumer keying on the
|
|
326
|
+
// slug couldn't tell "never retry" from "do retry".
|
|
340
327
|
if (status !== undefined && status >= 400) {
|
|
341
328
|
return {
|
|
342
329
|
error: 'bad_request',
|
|
@@ -397,18 +384,14 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
|
|
|
397
384
|
hint: rendered?.hint ??
|
|
398
385
|
(`Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
|
|
399
386
|
'If it persists on a high-latency or broken-IPv6 link, raise the happy-eyeballs budget: NODE_OPTIONS=--network-family-autoselection-attempt-timeout=4000 (server default 2000ms), and check connectivity with curl.'),
|
|
400
|
-
// Not an override site
|
|
401
|
-
// may flip): a multi-tenant host keeps core's retriable codes for
|
|
402
|
-
// network_error.
|
|
387
|
+
// Not an override site: a multi-tenant host keeps core's retriable codes for network_error.
|
|
403
388
|
retriable: RETRIABLE_NET_CODES.has(netCode),
|
|
404
389
|
account,
|
|
405
390
|
};
|
|
406
391
|
}
|
|
407
392
|
}
|
|
408
|
-
// A gaxios-shaped error
|
|
409
|
-
//
|
|
410
|
-
// handler, a missing key), and calling that `upstream_error` blamed Google
|
|
411
|
-
// for our own bug: an empty messageId used to surface exactly that way.
|
|
393
|
+
// A gaxios-shaped error means the request left this process; anything else
|
|
394
|
+
// threw INSIDE the server, and calling that `upstream_error` blamed Google for our own bug.
|
|
412
395
|
if (error?.response !== undefined || error?.config !== undefined) {
|
|
413
396
|
return {
|
|
414
397
|
error: 'upstream_error',
|
|
@@ -21,19 +21,15 @@ export function hostFilesRefused(account, what) {
|
|
|
21
21
|
// path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
|
|
22
22
|
export function prepareLocalDest(savePath, filename) {
|
|
23
23
|
const name = path.basename(filename);
|
|
24
|
-
// Agents routinely
|
|
25
|
-
//
|
|
26
|
-
// file and bury the download inside it, so strip the duplicated leaf.
|
|
24
|
+
// Agents routinely repeat the leaf in both savePath and filename; a blind
|
|
25
|
+
// join would bury the download in a directory named like the file itself.
|
|
27
26
|
const dir = path.basename(savePath) === name ? path.dirname(savePath) : savePath;
|
|
28
27
|
const dest = path.join(dir, name);
|
|
29
28
|
fs.mkdirSync(dir, { recursive: true });
|
|
30
29
|
return dest;
|
|
31
30
|
}
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// for the shared HTTP transport. Opening the fd first turns the open-failure
|
|
35
|
-
// class (ENOENT/EACCES/...) into a normal rejection the caller's try/catch can
|
|
36
|
-
// map to an error envelope.
|
|
31
|
+
// createReadStream's open failure surfaces as an unhandled 'error' event that
|
|
32
|
+
// kills the process; opening the fd first turns it into a normal rejection.
|
|
37
33
|
export async function openLocalReadStream(localPath) {
|
|
38
34
|
const handle = await fs.promises.open(localPath, 'r');
|
|
39
35
|
// open() succeeds on a directory; fail it here rather than as an async read error.
|
|
@@ -9,8 +9,9 @@ export interface AddForm {
|
|
|
9
9
|
otherBundles: string;
|
|
10
10
|
admin: boolean;
|
|
11
11
|
}
|
|
12
|
-
/** Every optional bundle (admin is granted via its own checkbox, not here)
|
|
13
|
-
|
|
12
|
+
/** Every optional bundle (admin is granted via its own checkbox, not here),
|
|
13
|
+
* less those Google refuses for the account at `email` when one is given. */
|
|
14
|
+
export declare function allOptionalBundles(email?: string): string[];
|
|
14
15
|
/** elicitation/create form schema (flat primitives only, per the MCP spec). */
|
|
15
16
|
export declare function addFormSchema(): Record<string, unknown>;
|
|
16
17
|
export type AddValidation = {
|
|
@@ -53,7 +54,7 @@ export declare function argsToAddForm(a: {
|
|
|
53
54
|
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
54
55
|
* consent / unchecked bundles). Pure. */
|
|
55
56
|
export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
|
|
56
|
-
/** HTTP-transport consent context
|
|
57
|
+
/** HTTP-transport consent context: set by the HTTP bootstrap. When
|
|
57
58
|
* present, runConsent NEVER binds a server-side loopback listener — the
|
|
58
59
|
* user's browser cannot reach the daemon's loopback — and instead hands out
|
|
59
60
|
* a clientless AS consent URL. The injector decides the flow (single-owner =
|
|
@@ -3,19 +3,23 @@ import { getAccountSet, invalidateAccountSet, refreshAccountSetIfStale } from '.
|
|
|
3
3
|
import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
|
|
4
4
|
import { writeToken } from '../token-store.js';
|
|
5
5
|
import { resolveScopesForAccount } from '../auth.js';
|
|
6
|
-
import { BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from '../scope-catalog.js';
|
|
6
|
+
import { BUNDLE_CATALOG, closestBundle, isKnownBundle, refusedBundlesFor, resolveBundleAliases } from '../scope-catalog.js';
|
|
7
7
|
import { openUrl } from '../open-url.js';
|
|
8
8
|
import { coerceBoolean } from './_coerce.js';
|
|
9
9
|
import { safeMessage, stringifyEnvelope } from './_errors.js';
|
|
10
10
|
import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
|
|
11
11
|
import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
|
|
12
|
-
//
|
|
13
|
-
// multi-select
|
|
14
|
-
// a comma-separated field for power users, and `allBundles` grants every one.
|
|
12
|
+
// Each common bundle gets its own checkbox since MCP elicitation has no
|
|
13
|
+
// multi-select; the long tail stays a comma-separated field, `allBundles` grants all.
|
|
15
14
|
const CHECKBOX_BUNDLES = ['forms', 'chat'];
|
|
16
|
-
/** Every optional bundle (admin is granted via its own checkbox, not here)
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
/** Every optional bundle (admin is granted via its own checkbox, not here),
|
|
16
|
+
* less those Google refuses for the account at `email` when one is given. */
|
|
17
|
+
export function allOptionalBundles(email) {
|
|
18
|
+
const all = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin');
|
|
19
|
+
if (email === undefined)
|
|
20
|
+
return all;
|
|
21
|
+
const refused = new Set(refusedBundlesFor(email, all));
|
|
22
|
+
return all.filter((n) => !refused.has(n));
|
|
19
23
|
}
|
|
20
24
|
/** elicitation/create form schema (flat primitives only, per the MCP spec). */
|
|
21
25
|
export function addFormSchema() {
|
|
@@ -48,8 +52,7 @@ export function validateAddForm(input, existingAliases) {
|
|
|
48
52
|
return { ok: false, slug: 'E_VALIDATION', message: 'Email is required.', hint: "It is used as the Google login hint, so it must be the account's real Google address.", alias };
|
|
49
53
|
}
|
|
50
54
|
if (input.allBundles === true) {
|
|
51
|
-
|
|
52
|
-
return { ok: true, alias, email, bundles: allOptionalBundles(), admin: input.admin === true };
|
|
55
|
+
return { ok: true, alias, email, bundles: allOptionalBundles(email), admin: input.admin === true };
|
|
53
56
|
}
|
|
54
57
|
const picked = CHECKBOX_BUNDLES.filter((b) => input[b] === true);
|
|
55
58
|
const rawOther = (input.otherBundles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -70,6 +73,16 @@ export function validateAddForm(input, existingAliases) {
|
|
|
70
73
|
};
|
|
71
74
|
}
|
|
72
75
|
}
|
|
76
|
+
const refused = refusedBundlesFor(email, bundles);
|
|
77
|
+
if (refused.length > 0) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
slug: 'E_VALIDATION',
|
|
81
|
+
message: `Google does not let a personal account grant ${refused.map((b) => `"${b}"`).join(', ')}.`,
|
|
82
|
+
hint: 'Leave it out, or link a Google Workspace account for it.',
|
|
83
|
+
alias,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
73
86
|
return { ok: true, alias, email, bundles, admin: input.admin === true };
|
|
74
87
|
}
|
|
75
88
|
/** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
|
|
@@ -102,8 +115,6 @@ export function scopeGrantDiff(requested, grantedScope) {
|
|
|
102
115
|
const granted = new Set((grantedScope ?? '').split(' ').filter(Boolean));
|
|
103
116
|
return requested.filter((s) => !granted.has(s));
|
|
104
117
|
}
|
|
105
|
-
/** Persist a new account row (+ a per-account scope profile when bundles/admin
|
|
106
|
-
* were chosen) through the atomic registry mutation path (BR2). */
|
|
107
118
|
function writeAccountRow(alias, email, bundles, admin) {
|
|
108
119
|
mutateConfigFile((current) => {
|
|
109
120
|
const next = { ...current, accounts: { ...(current.accounts ?? {}) } };
|
|
@@ -121,9 +132,8 @@ function writeAccountRow(alias, email, bundles, admin) {
|
|
|
121
132
|
function textResult(text) {
|
|
122
133
|
return { content: [{ type: 'text', text }] };
|
|
123
134
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
* it is a human-readable onboarding report, not data. */
|
|
135
|
+
// Errors use the shared envelope so clients can branch on slug; success stays
|
|
136
|
+
// plain prose (a human-readable report, not data).
|
|
127
137
|
function errorResult(slug, message, hint, account) {
|
|
128
138
|
return {
|
|
129
139
|
content: [{ type: 'text', text: stringifyEnvelope({
|
|
@@ -140,9 +150,8 @@ let httpConsent = null;
|
|
|
140
150
|
export function setWizardHttpConsent(ctx) {
|
|
141
151
|
httpConsent = ctx;
|
|
142
152
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
* whole account works without one. */
|
|
153
|
+
// Services register tools at startup: an account's first-enabled service
|
|
154
|
+
// stays unregistered until a restart, so say so rather than claim it just works.
|
|
146
155
|
function restartNote(pending) {
|
|
147
156
|
if (pending.length === 0)
|
|
148
157
|
return '';
|
|
@@ -155,9 +164,6 @@ function pendingText(alias, url, pending) {
|
|
|
155
164
|
`On completion the token is stored server-side and the account is usable immediately — no restart. ` +
|
|
156
165
|
`If the link expires, run account_reauth for a fresh one.${restartNote(pending)}\n${TESTING_MODE_WARNING}`);
|
|
157
166
|
}
|
|
158
|
-
/** Run Google consent for `alias` and persist the token. Opens the browser via
|
|
159
|
-
* URL-mode elicitation when the client supports it, else server-side + prints
|
|
160
|
-
* the URL. Returns the granted-scope diff for the S4 report. */
|
|
161
167
|
async function runConsent(server, alias) {
|
|
162
168
|
const { randomBytes } = await import('node:crypto');
|
|
163
169
|
const cfg = getAccountSet().configs[alias];
|
|
@@ -169,9 +175,8 @@ async function runConsent(server, alias) {
|
|
|
169
175
|
hint: 'Accounts sourced from GOOGLE_ACCOUNTS are not editable here. Run `mcp-google-multi migrate-config` to move them into config.json.',
|
|
170
176
|
};
|
|
171
177
|
}
|
|
172
|
-
// HTTP
|
|
173
|
-
//
|
|
174
|
-
// but surface the URL UP FRONT either way (not only on failure).
|
|
178
|
+
// HTTP: consent completes out-of-band at the AS /callback; never bind a
|
|
179
|
+
// loopback listener; surface the URL up front, elicitation is just a convenience.
|
|
175
180
|
const http = httpConsent;
|
|
176
181
|
if (http) {
|
|
177
182
|
let url;
|
|
@@ -187,14 +192,13 @@ async function runConsent(server, alias) {
|
|
|
187
192
|
await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
|
|
188
193
|
}
|
|
189
194
|
catch {
|
|
190
|
-
//
|
|
195
|
+
// pending text has the recovery URL
|
|
191
196
|
}
|
|
192
197
|
}
|
|
193
198
|
return { ok: 'pending', url };
|
|
194
199
|
}
|
|
195
|
-
// Bind the
|
|
196
|
-
//
|
|
197
|
-
// callback can't race the browser.
|
|
200
|
+
// Bind the loopback listener before building the auth URL: the redirect URI
|
|
201
|
+
// needs the assigned port, and listening first avoids a race with the callback.
|
|
198
202
|
const loop = await openLoopbackConsent();
|
|
199
203
|
const client = buildConsentClient(loop.redirect);
|
|
200
204
|
const expectedState = randomBytes(32).toString('hex');
|
|
@@ -207,9 +211,8 @@ async function runConsent(server, alias) {
|
|
|
207
211
|
let opened = false;
|
|
208
212
|
if (caps?.elicitation?.url !== undefined) {
|
|
209
213
|
try {
|
|
210
|
-
// elicitationId is REQUIRED by the spec
|
|
211
|
-
//
|
|
212
|
-
// fell through to the server-side browser open.
|
|
214
|
+
// elicitationId is REQUIRED by the spec; omitting it silently fails
|
|
215
|
+
// client-side validation, falling through to the server-side browser open.
|
|
213
216
|
const r = await server.server.elicitInput(urlElicitationParams(alias, url, randomBytes(16).toString('hex')));
|
|
214
217
|
if (r.action !== 'accept') {
|
|
215
218
|
loop.close();
|
|
@@ -254,9 +257,8 @@ function s4Text(alias, missing, pending) {
|
|
|
254
257
|
}
|
|
255
258
|
const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
|
|
256
259
|
export function registerAccountWizardTools(registry, server, awaitingRestart = () => []) {
|
|
257
|
-
// Registered as
|
|
258
|
-
//
|
|
259
|
-
// preserves the requiresUserInteraction clientMeta and skips the fan-out path.
|
|
260
|
+
// Registered as meta (like account_list) so onboarding tools are reachable in
|
|
261
|
+
// lazy mode without discover_all; this still preserves requiresUserInteraction and skips fan-out.
|
|
260
262
|
const registerMeta = registry.registerMeta;
|
|
261
263
|
registerMeta('account_add', {
|
|
262
264
|
_meta: REQUIRES_INTERACTION,
|
|
@@ -272,18 +274,16 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
272
274
|
},
|
|
273
275
|
}, async (args) => {
|
|
274
276
|
try {
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// write. Refuse up front (BR-10) rather than write a row nothing reads.
|
|
277
|
+
// GOOGLE_ACCOUNTS is the exclusive registry source when set; config.json
|
|
278
|
+
// is ignored, so refuse here rather than write a row nothing reads.
|
|
278
279
|
if (process.env.GOOGLE_ACCOUNTS?.trim()) {
|
|
279
280
|
return errorResult('E_ENV_ACCOUNTS_MODE', 'Accounts are defined by the GOOGLE_ACCOUNTS environment variable, so a new account cannot be added interactively: config.json is ignored while it is set, and the row would be written where nothing reads it.', 'Either add the alias to GOOGLE_ACCOUNTS and run account_reauth, or run `mcp-google-multi migrate-config` to move accounts into config.json and unset GOOGLE_ACCOUNTS.');
|
|
280
281
|
}
|
|
281
282
|
if (!hasClientCredentials()) {
|
|
282
283
|
return errorResult('invalid_client', 'GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set, so there is no OAuth client to run consent against.', 'Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them. See docs/google-cloud-setup.md.');
|
|
283
284
|
}
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
// interactive path used to dead-end).
|
|
285
|
+
// Arguments win over the form so the wizard still works for clients
|
|
286
|
+
// without form elicitation support.
|
|
287
287
|
const a = (args ?? {});
|
|
288
288
|
let input;
|
|
289
289
|
if (a.alias?.trim() || a.account?.trim() || a.email?.trim()) {
|
|
@@ -304,25 +304,22 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
304
304
|
const validated = validateAddForm(input, getAccountSet().aliases);
|
|
305
305
|
if (!validated.ok)
|
|
306
306
|
return errorResult(validated.slug, validated.message, validated.hint, validated.alias);
|
|
307
|
-
//
|
|
307
|
+
// Atomic write + make the alias callable without a restart.
|
|
308
308
|
writeAccountRow(validated.alias, validated.email, validated.bundles, validated.admin);
|
|
309
309
|
invalidateAccountSet();
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// advertised account enums are rebuilt from the live set.
|
|
310
|
+
// Account args already validate against the refreshed registry; the
|
|
311
|
+
// nudge just gets clients to re-fetch tools/list for the new account enum.
|
|
313
312
|
try {
|
|
314
313
|
server.sendToolListChanged();
|
|
315
314
|
}
|
|
316
315
|
catch {
|
|
317
|
-
//
|
|
316
|
+
// harmless if unsupported
|
|
318
317
|
}
|
|
319
|
-
// S3 + S4: consent + validate.
|
|
320
318
|
const consent = await runConsent(server, validated.alias);
|
|
321
319
|
if (consent.ok === 'pending')
|
|
322
320
|
return textResult(pendingText(validated.alias, consent.url, awaitingRestart()));
|
|
323
321
|
if (!consent.ok)
|
|
324
322
|
return errorResult(consent.slug, consent.message, consent.hint, validated.alias);
|
|
325
|
-
// B15: offer to register the server with another MCP client.
|
|
326
323
|
return textResult(`${s4Text(validated.alias, consent.missing, awaitingRestart())}\nTip: run account_write_config to register this server with another MCP client (Claude Desktop / Cursor / Claude Code).`);
|
|
327
324
|
}
|
|
328
325
|
catch (e) {
|
|
@@ -339,10 +336,8 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
339
336
|
// Plain string (NOT the account enum) so this never joins the
|
|
340
337
|
// multi-account fan-out path; validated against the registry below.
|
|
341
338
|
alias: z.string().min(1).optional().describe('Existing account alias to re-authenticate'),
|
|
342
|
-
// Every other tool
|
|
343
|
-
//
|
|
344
|
-
// no did-you-mean can rescue: `account` is 5 edits from `alias`, so
|
|
345
|
-
// the matcher cannot bridge them.
|
|
339
|
+
// Every other tool spells this `account`; accepting both avoids a dead
|
|
340
|
+
// end no did-you-mean can bridge (too many edits from `alias`).
|
|
346
341
|
account: z.string().min(1).optional().describe('Alias of the account to re-authenticate (same as `alias`)'),
|
|
347
342
|
},
|
|
348
343
|
}, async (args) => {
|
|
@@ -391,7 +386,7 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
391
386
|
resourceUri = m.resourceUri;
|
|
392
387
|
}
|
|
393
388
|
catch {
|
|
394
|
-
//
|
|
389
|
+
// fallback to stdio on error
|
|
395
390
|
}
|
|
396
391
|
let entry;
|
|
397
392
|
try {
|
|
@@ -412,9 +407,8 @@ export function registerAccountWizardTools(registry, server, awaitingRestart = (
|
|
|
412
407
|
blocks.push(`${client.label}: run\n ${instr.text}`);
|
|
413
408
|
}
|
|
414
409
|
else if (a.write && mode === 'http') {
|
|
415
|
-
// Over HTTP this
|
|
416
|
-
//
|
|
417
|
-
// configs (a cross-tenant hazard under tenancy). Snippet only.
|
|
410
|
+
// Over HTTP this runs on the daemon, not the caller's machine: write:true
|
|
411
|
+
// would edit the operator's own client configs (a tenancy hazard), so snippet only.
|
|
418
412
|
blocks.push(`${client.label}: write:true is ignored over HTTP — this tool runs on the server, not your machine. Add to ${instr.path}\n${instr.text}`);
|
|
419
413
|
}
|
|
420
414
|
else if (a.write) {
|
|
@@ -12,7 +12,7 @@ export interface AccountHealthDeps {
|
|
|
12
12
|
fileExists: (p: string) => boolean;
|
|
13
13
|
now: () => number;
|
|
14
14
|
/** Scopes required by registered tools — feeds doctor's tool-grained
|
|
15
|
-
* "registered but not requestable" view
|
|
15
|
+
* "registered but not requestable" view. account_list stays compact:
|
|
16
16
|
* its report universe is profile ∪ granted only. */
|
|
17
17
|
registeredScopes?: () => string[];
|
|
18
18
|
/** Account view health is derived against; absent = the global registry. */
|
|
@@ -22,7 +22,7 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
|
|
|
22
22
|
const config = set.configs[alias];
|
|
23
23
|
const admin = getAdminAccounts(set).includes(alias);
|
|
24
24
|
const configured = resolveScopesForAccount(alias, set);
|
|
25
|
-
//
|
|
25
|
+
// Env-sourced accounts are not persisted in config.json, so the
|
|
26
26
|
// account wizard cannot edit them — the deployer edits env instead.
|
|
27
27
|
const base = {
|
|
28
28
|
alias,
|
package/dist/tools/admin.js
CHANGED
|
@@ -6,9 +6,8 @@ import { getClient } from '../client.js';
|
|
|
6
6
|
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
7
7
|
// Admin SDK requires Workspace super-admin (or delegated admin) on the account — personal @gmail.com accounts 403 on every endpoint.
|
|
8
8
|
export function registerAdminTools(server, deps = {}) {
|
|
9
|
-
// Per-registry,
|
|
10
|
-
//
|
|
11
|
-
// custody path is the context's, not the process global.
|
|
9
|
+
// Per-registry, live account enum + injectable client: the schema follows
|
|
10
|
+
// the registry's account view at parse time, not a process-global default.
|
|
12
11
|
const accountEnum = accountArgLive(() => server.accountAliases()).optional();
|
|
13
12
|
const getClientFn = deps.getClientFn ?? getClient;
|
|
14
13
|
// ─── Reports / audit log ───────────────────────────────────────────────
|
package/dist/tools/analytics.js
CHANGED
|
@@ -105,9 +105,8 @@ const propertySchema = z
|
|
|
105
105
|
.string()
|
|
106
106
|
.describe('GA4 property: numeric ID like "213025502" or "properties/213025502" — NOT a "G-..." measurement ID and not "UA-...". Find yours with analytics_account_summaries.');
|
|
107
107
|
export function registerAnalyticsTools(server, deps = {}) {
|
|
108
|
-
// Per-registry,
|
|
109
|
-
//
|
|
110
|
-
// custody path is the context's, not the process global.
|
|
108
|
+
// Per-registry, live account enum + injectable client: the schema follows
|
|
109
|
+
// the registry's account view at parse time, not a process-global default.
|
|
111
110
|
const accountEnum = accountArgLive(() => server.accountAliases()).optional();
|
|
112
111
|
const getClientFn = deps.getClientFn ?? getClient;
|
|
113
112
|
server.registerTool('analytics_account_summaries', {
|
|
@@ -281,8 +280,8 @@ function invalidProperty(hint, account) {
|
|
|
281
280
|
export function handleAnalyticsError(error, account) {
|
|
282
281
|
return handleGoogleApiError(error, account, {
|
|
283
282
|
scope: 'Needs the "analytics" bundle on this account: add it to the scope profile, then re-auth.',
|
|
284
|
-
// GA4
|
|
285
|
-
//
|
|
283
|
+
// GA4 returns PERMISSION_DENIED for a property-access issue too, not just
|
|
284
|
+
// a missing scope; keep the hints distinct, or callers get sent to re-auth a scope they already have.
|
|
286
285
|
resource: 'The scope is not the problem: this Google account has no access to that GA4 property. Grant it in GA4 Admin > Property Access Management, or call analytics_account_summaries to see the properties it can read.',
|
|
287
286
|
bundle: 'analytics',
|
|
288
287
|
});
|
package/dist/tools/calendar.js
CHANGED
|
@@ -7,9 +7,8 @@ import { checkOutbound } from '../outbound-allowlist.js';
|
|
|
7
7
|
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
8
8
|
import { listResult, sliceClean } from '../trim.js';
|
|
9
9
|
export function registerCalendarTools(server, deps = {}) {
|
|
10
|
-
// Per-registry,
|
|
11
|
-
//
|
|
12
|
-
// custody path is the context's, not the process global.
|
|
10
|
+
// Per-registry, live account enum + injectable client: the schema follows
|
|
11
|
+
// the registry's account view at parse time, not a process-global default.
|
|
13
12
|
const accountEnum = accountArgLive(() => server.accountAliases()).optional();
|
|
14
13
|
const getClientFn = deps.getClientFn ?? getClient;
|
|
15
14
|
server.registerTool('calendar_list_calendars', {
|
|
@@ -382,10 +381,8 @@ export function registerCalendarTools(server, deps = {}) {
|
|
|
382
381
|
},
|
|
383
382
|
});
|
|
384
383
|
return {
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// string: the SDK then rejected our own result with -32602. The
|
|
388
|
-
// value is a map keyed by calendar id, so the fallback is {}.
|
|
384
|
+
// Empty items returns 200 with no `calendars` key; JSON.stringify(undefined)
|
|
385
|
+
// would make the SDK reject our own result, so fall back to {}.
|
|
389
386
|
content: [{ type: 'text', text: JSON.stringify(res.data.calendars ?? {}, null, 2) }],
|
|
390
387
|
};
|
|
391
388
|
}
|
package/dist/tools/chat.js
CHANGED
|
@@ -5,9 +5,8 @@ import { accountArgLive } from '../accounts.js';
|
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
7
7
|
export function registerChatTools(server, deps = {}) {
|
|
8
|
-
// Per-registry,
|
|
9
|
-
//
|
|
10
|
-
// custody path is the context's, not the process global.
|
|
8
|
+
// Per-registry, live account enum + injectable client: the schema follows
|
|
9
|
+
// the registry's account view at parse time, not a process-global default.
|
|
11
10
|
const accountEnum = accountArgLive(() => server.accountAliases()).optional();
|
|
12
11
|
const getClientFn = deps.getClientFn ?? getClient;
|
|
13
12
|
server.registerTool('chat_spaces_list', {
|
package/dist/tools/contacts.js
CHANGED
|
@@ -34,7 +34,7 @@ function formatContact(person) {
|
|
|
34
34
|
photo: person.photos?.[0]?.url ?? '',
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
|
-
/** Accent-fold + lowercase for name comparison ("
|
|
37
|
+
/** Accent-fold + lowercase for name comparison ("Zoe" == "Zoë"). */
|
|
38
38
|
const foldName = (s) => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim();
|
|
39
39
|
/** Canonical-address preference within one contact: explicit primary, then
|
|
40
40
|
* People type order work > home > other. */
|
|
@@ -52,7 +52,7 @@ function pickEmail(c) {
|
|
|
52
52
|
return [...c.emails].sort((a, b) => emailRank(b) - emailRank(a))[0];
|
|
53
53
|
}
|
|
54
54
|
/** Deterministic rank tuple; higher wins, compared lexicographically so the
|
|
55
|
-
* first discriminating rule decides
|
|
55
|
+
* first discriminating rule decides. */
|
|
56
56
|
function scoreTuple(c, foldedQuery) {
|
|
57
57
|
const exact = foldName(c.displayName) === foldedQuery ? 1 : 0; // 1. exact over prefix
|
|
58
58
|
const saved = c.source === 'contacts' ? 1 : 0; // 2. saved over other-contact
|
|
@@ -123,9 +123,8 @@ function dedupeCandidates(list) {
|
|
|
123
123
|
return [...byResource.values()];
|
|
124
124
|
}
|
|
125
125
|
export function registerContactsTools(server, deps = {}) {
|
|
126
|
-
// Per-registry,
|
|
127
|
-
//
|
|
128
|
-
// custody path is the context's, not the process global.
|
|
126
|
+
// Per-registry, live account enum + injectable client: the schema follows
|
|
127
|
+
// the registry's account view at parse time, not a process-global default.
|
|
129
128
|
const accountEnum = accountArgLive(() => server.accountAliases()).optional();
|
|
130
129
|
const getClientFn = deps.getClientFn ?? getClient;
|
|
131
130
|
server.registerTool('contacts_search', {
|
|
@@ -170,7 +169,7 @@ export function registerContactsTools(server, deps = {}) {
|
|
|
170
169
|
description: 'Resolve a free-text name/alias to ONE canonical email address (ranked + tie-broken), so you need not hand-expand transliteration OR-queries. Returns a single confident match, an explicit ambiguous candidate list, or null when nothing matches. For picking a recipient before gmail_send.',
|
|
171
170
|
inputSchema: {
|
|
172
171
|
account: accountEnum.describe('Google account alias'),
|
|
173
|
-
name: z.string().min(1).describe('Free-text name or alias to resolve (e.g. "
|
|
172
|
+
name: z.string().min(1).describe('Free-text name or alias to resolve (e.g. "Zoe")'),
|
|
174
173
|
maxCandidates: z.number().min(1).max(10).default(5).optional()
|
|
175
174
|
.describe('Max candidates to return when ambiguous (1-10, default: 5)'),
|
|
176
175
|
includeOtherContacts: coerceBoolean.optional()
|
|
@@ -197,7 +196,7 @@ export function registerContactsTools(server, deps = {}) {
|
|
|
197
196
|
candidates.push(...(other.data.results ?? []).map((r) => toCandidate(r.person, 'otherContacts')));
|
|
198
197
|
}
|
|
199
198
|
catch {
|
|
200
|
-
// otherContacts unavailable
|
|
199
|
+
// otherContacts unavailable: degrade silently
|
|
201
200
|
}
|
|
202
201
|
}
|
|
203
202
|
const result = resolveContacts(dedupeCandidates(candidates), name, maxCandidates ?? 5);
|
|
@@ -453,8 +452,8 @@ export function registerContactsTools(server, deps = {}) {
|
|
|
453
452
|
const members = [];
|
|
454
453
|
let fetchFailures = 0;
|
|
455
454
|
let firstError;
|
|
456
|
-
// getBatchGet caps at 200 names while maxMembers goes to 1000, so
|
|
457
|
-
//
|
|
455
|
+
// getBatchGet caps at 200 names while maxMembers goes to 1000, so fetch
|
|
456
|
+
// in chunks rather than fail outright on a large group.
|
|
458
457
|
for (let i = 0; i < memberResourceNames.length; i += BATCH_GET_LIMIT) {
|
|
459
458
|
const chunk = memberResourceNames.slice(i, i + BATCH_GET_LIMIT);
|
|
460
459
|
try {
|
package/dist/tools/docs.js
CHANGED
|
@@ -5,12 +5,8 @@ import { accountArgLive } from '../accounts.js';
|
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
6
|
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
7
7
|
import { capText } from '../trim.js';
|
|
8
|
-
// A single
|
|
9
|
-
//
|
|
10
|
-
// insert into bounded pieces sent as sequential insertText requests in one atomic
|
|
11
|
-
// batchUpdate: small inserts keep the exact single-request path, and a large one
|
|
12
|
-
// can no longer be silently truncated. Docs indices are UTF-16 code units (= JS
|
|
13
|
-
// string length), so index math advances by `.length`; never split a surrogate pair.
|
|
8
|
+
// A single large insertText can silently apply only partially despite returning
|
|
9
|
+
// success; split into <=DOCS_INSERT_CHUNK sequential inserts in one atomic batchUpdate (UTF-16-safe, no split surrogate pairs).
|
|
14
10
|
export const DOCS_INSERT_CHUNK = 5000;
|
|
15
11
|
export function splitInsertText(text, maxLen = DOCS_INSERT_CHUNK) {
|
|
16
12
|
if (text.length === 0)
|
|
@@ -170,9 +166,8 @@ export function listTabs(tabs) {
|
|
|
170
166
|
return out;
|
|
171
167
|
}
|
|
172
168
|
export function registerDocsTools(server, deps = {}) {
|
|
173
|
-
// Per-registry,
|
|
174
|
-
//
|
|
175
|
-
// custody path is the context's, not the process global.
|
|
169
|
+
// Per-registry, live account enum + injectable client: the schema follows
|
|
170
|
+
// the registry's account view at parse time, not a process-global default.
|
|
176
171
|
const accountEnum = accountArgLive(() => server.accountAliases()).optional();
|
|
177
172
|
const getClientFn = deps.getClientFn ?? getClient;
|
|
178
173
|
server.registerTool('docs_create', {
|