mcp-google-multi 6.0.0-beta.1 → 6.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/accounts.d.ts +5 -0
- package/dist/accounts.js +12 -1
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +61 -4
- package/dist/arg-normalize.js +228 -4
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +96 -17
- package/dist/doctor.d.ts +9 -1
- package/dist/doctor.js +32 -4
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.js +43 -8
- package/dist/http-transport.d.ts +11 -2
- package/dist/http-transport.js +10 -5
- package/dist/index.js +112 -12
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/registry.d.ts +49 -2
- package/dist/registry.js +147 -7
- package/dist/scope-catalog.js +16 -0
- package/dist/services.d.ts +8 -0
- package/dist/services.js +28 -1
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +31 -3
- package/dist/tools/_errors.js +307 -43
- package/dist/tools/_local-files.js +7 -2
- package/dist/tools/account-wizard.d.ts +24 -1
- package/dist/tools/account-wizard.js +152 -51
- package/dist/tools/admin.js +7 -6
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +284 -0
- package/dist/tools/calendar.d.ts +7 -0
- package/dist/tools/calendar.js +75 -15
- package/dist/tools/chat.js +7 -4
- package/dist/tools/contacts.js +57 -30
- package/dist/tools/docs.js +56 -54
- package/dist/tools/drive.js +132 -70
- package/dist/tools/forms.js +7 -7
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +345 -214
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.js +4 -4
- package/dist/tools/generated/calendar.js +28 -28
- package/dist/tools/generated/chat.js +83 -56
- package/dist/tools/generated/classroom.js +258 -216
- package/dist/tools/generated/cloudidentity.js +76 -68
- package/dist/tools/generated/cloudsearch.js +40 -39
- package/dist/tools/generated/contacts.js +20 -11
- package/dist/tools/generated/drive.js +62 -53
- package/dist/tools/generated/drivelabels.js +60 -32
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.js +136 -109
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +8 -7
- package/dist/tools/generated/licensing.js +39 -23
- package/dist/tools/generated/meet.js +14 -14
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.js +3 -3
- package/dist/tools/generated/reseller.js +34 -28
- package/dist/tools/generated/script.js +29 -19
- package/dist/tools/generated/searchconsole.js +5 -3
- package/dist/tools/generated/sheets.js +10 -9
- package/dist/tools/generated/tasks.js +10 -4
- package/dist/tools/generated/vault.js +65 -56
- package/dist/tools/generated/workspaceevents.js +17 -16
- package/dist/tools/gmail.d.ts +10 -1
- package/dist/tools/gmail.js +50 -18
- package/dist/tools/google-api.js +48 -19
- package/dist/tools/meet.js +5 -2
- package/dist/tools/searchconsole.js +10 -10
- package/dist/tools/sheets.js +36 -48
- package/dist/tools/slides.js +6 -6
- package/dist/tools/tasks.js +16 -16
- package/dist/trim.d.ts +21 -0
- package/dist/trim.js +47 -0
- package/dist/usage-metrics.d.ts +155 -0
- package/dist/usage-metrics.js +603 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- package/package.json +8 -3
package/dist/tools/_errors.js
CHANGED
|
@@ -1,16 +1,111 @@
|
|
|
1
1
|
import { reauthHint } from '../reauth-hint.js';
|
|
2
|
+
import { sliceClean, sliceEncoded } from '../trim.js';
|
|
3
|
+
const MAX_ERROR_MESSAGE_CHARS = 1000;
|
|
4
|
+
const MAX_ERROR_ENVELOPE_CHARS = 4000;
|
|
5
|
+
const MAX_BODY_PARSE_CHARS = 64_000;
|
|
6
|
+
function normalizeHints(h) {
|
|
7
|
+
return typeof h === 'string' ? { scope: h } : (h ?? {});
|
|
8
|
+
}
|
|
2
9
|
function statusOf(error) {
|
|
3
10
|
const c = error?.code ?? error?.status ?? error?.response?.status;
|
|
4
11
|
const n = typeof c === 'string' ? Number(c) : c;
|
|
5
12
|
return Number.isFinite(n) ? n : undefined;
|
|
6
13
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
/** gaxios 7 is fetch-based, so response.headers is a Headers instance with no
|
|
15
|
+
* index signature; the plain-object form still appears in tests and in mocks. */
|
|
16
|
+
function headerOf(error, name) {
|
|
17
|
+
const h = error?.response?.headers;
|
|
18
|
+
if (!h)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (typeof h.get === 'function') {
|
|
21
|
+
const v = h.get(name);
|
|
22
|
+
return typeof v === 'string' ? v : undefined;
|
|
23
|
+
}
|
|
24
|
+
const key = Object.keys(h).find((k) => k.toLowerCase() === name);
|
|
25
|
+
return key === undefined ? undefined : String(h[key]);
|
|
26
|
+
}
|
|
27
|
+
/** gaxios hands `response.data` back as a STRING whenever it could not parse
|
|
28
|
+
* JSON, and on every non-2xx of a responseType:'stream' call (drive_download,
|
|
29
|
+
* drive_export), where it buffers the whole body to utf8 before throwing. So
|
|
30
|
+
* the JSON error of a failed download is invisible to a plain property read. */
|
|
31
|
+
function errorPayload(error) {
|
|
32
|
+
const data = error?.response?.data;
|
|
33
|
+
if (data && typeof data === 'object')
|
|
34
|
+
return data;
|
|
35
|
+
if (typeof data !== 'string')
|
|
36
|
+
return undefined;
|
|
37
|
+
const ct = headerOf(error, 'content-type')?.toLowerCase();
|
|
38
|
+
if (ct && !ct.includes('json'))
|
|
39
|
+
return undefined;
|
|
40
|
+
if (data.length > MAX_BODY_PARSE_CHARS)
|
|
41
|
+
return undefined;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(data);
|
|
44
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** AIP-193 google.rpc.ErrorInfo. Modern Workspace APIs (Chat, Meet, Workspace
|
|
51
|
+
* Events, Analytics Data, People) put the machine-readable reason here and
|
|
52
|
+
* leave errors[0].reason empty, which is why their 403s used to fall through. */
|
|
53
|
+
function errorInfoOf(payload) {
|
|
54
|
+
const details = payload?.error?.details;
|
|
55
|
+
if (!Array.isArray(details))
|
|
56
|
+
return undefined;
|
|
57
|
+
const info = details.find((d) => typeof d?.['@type'] === 'string' && d['@type'].endsWith('/google.rpc.ErrorInfo'));
|
|
58
|
+
if (!info)
|
|
59
|
+
return undefined;
|
|
60
|
+
return {
|
|
61
|
+
reason: typeof info.reason === 'string' ? info.reason : undefined,
|
|
62
|
+
domain: typeof info.domain === 'string' ? info.domain : undefined,
|
|
63
|
+
metadata: info.metadata && typeof info.metadata === 'object' ? info.metadata : undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function reasonOf(error, payload) {
|
|
67
|
+
return (errorInfoOf(payload)?.reason ??
|
|
68
|
+
error?.errors?.[0]?.reason ??
|
|
69
|
+
payload?.error?.errors?.[0]?.reason ??
|
|
70
|
+
payload?.error?.status);
|
|
71
|
+
}
|
|
72
|
+
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;
|
|
73
|
+
/** True when error.message is a response BODY rather than an error sentence.
|
|
74
|
+
* Content-Type first, shape second, markup sniff last, anchored at the start.
|
|
75
|
+
* NEVER matches page TEXT: the same URL answers "Page introuvable" to a
|
|
76
|
+
* French-locale account and "Page not found" to anonymous curl. */
|
|
77
|
+
function isRawBody(error, message) {
|
|
78
|
+
const ct = headerOf(error, 'content-type')?.toLowerCase();
|
|
79
|
+
if (ct)
|
|
80
|
+
return !ct.includes('json');
|
|
81
|
+
if (typeof error?.response?.data === 'string')
|
|
82
|
+
return true;
|
|
83
|
+
return MARKUP_START_RE.test(message);
|
|
84
|
+
}
|
|
85
|
+
function capMessage(text) {
|
|
86
|
+
return text.length <= MAX_ERROR_MESSAGE_CHARS
|
|
87
|
+
? text
|
|
88
|
+
: `${sliceClean(text, MAX_ERROR_MESSAGE_CHARS)} [truncated, ${text.length} chars total]`;
|
|
89
|
+
}
|
|
90
|
+
function bodyPlaceholder(error, status, length) {
|
|
91
|
+
const ct = headerOf(error, 'content-type')?.toLowerCase();
|
|
92
|
+
const kind = ct
|
|
93
|
+
? ct.includes('html')
|
|
94
|
+
? 'an HTML error page'
|
|
95
|
+
: `a non-JSON error body (${ct.split(';')[0].trim()})`
|
|
96
|
+
: 'a non-JSON error body';
|
|
97
|
+
const where = status === undefined ? '' : ` (HTTP ${status})`;
|
|
98
|
+
return `Google returned ${kind} instead of a JSON error${where}; ${length} chars suppressed.`;
|
|
11
99
|
}
|
|
12
|
-
|
|
13
|
-
|
|
100
|
+
/** The ONE extraction point for every Google-originated message. Without the
|
|
101
|
+
* raw-body guard, gaxios copies the entire response body into error.message,
|
|
102
|
+
* which put 8.5 KB of Google's HTML front-end page inside a JSON envelope. */
|
|
103
|
+
export function safeMessage(error, payload = errorPayload(error), status = statusOf(error)) {
|
|
104
|
+
const structured = payload?.error?.message;
|
|
105
|
+
if (typeof structured === 'string' && structured.trim() !== '')
|
|
106
|
+
return capMessage(structured);
|
|
107
|
+
const raw = typeof error?.message === 'string' ? error.message : String(error);
|
|
108
|
+
return isRawBody(error, raw) ? bodyPlaceholder(error, status, raw.length) : capMessage(raw);
|
|
14
109
|
}
|
|
15
110
|
// Connect/DNS syscall codes. ENOTFOUND (no such name) is the one non-transient
|
|
16
111
|
// member. node-fetch flattens the happy-eyeballs AggregateError to a bare code
|
|
@@ -53,16 +148,77 @@ function disabledApiId(message) {
|
|
|
53
148
|
return named[1].trim().toLowerCase().replace(/\s+/g, '');
|
|
54
149
|
return null;
|
|
55
150
|
}
|
|
56
|
-
|
|
151
|
+
// Several APIs report quota as a 403 with a quota reason (Drive, Calendar).
|
|
152
|
+
// Answering 'forbidden' with a sharing hint sends the agent the wrong way.
|
|
153
|
+
const FORBIDDEN_RATE_REASONS = new Set([
|
|
154
|
+
'rateLimitExceeded', 'userRateLimitExceeded', 'dailyLimitExceeded',
|
|
155
|
+
'sharingRateLimitExceeded', 'quotaExceeded', 'RATE_LIMIT_EXCEEDED', 'RESOURCE_EXHAUSTED',
|
|
156
|
+
]);
|
|
157
|
+
// Google already answered correctly: the file type is wrong for the tool, not
|
|
158
|
+
// the permissions. The escape hatch has said this for years (executor.ts); the
|
|
159
|
+
// curated pair used to discard it and blame sharing instead.
|
|
160
|
+
const WRONG_TOOL_HINTS = {
|
|
161
|
+
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.',
|
|
162
|
+
fileNotExportable: 'Export only works on Google Workspace native files. This one already has binary content: use drive_download to save it as is.',
|
|
163
|
+
};
|
|
164
|
+
function rateLimited(error, message, account, reason) {
|
|
165
|
+
// GA4 quota is a per-property token bucket, not a transient spike: shrinking
|
|
166
|
+
// the request is the lever, and a blind retry only burns more tokens.
|
|
167
|
+
if (/property tokens/i.test(message)) {
|
|
168
|
+
return {
|
|
169
|
+
error: 'rate_limited',
|
|
170
|
+
message,
|
|
171
|
+
hint: 'GA4 quotas are per-property token buckets that refill over the hour/day. ' +
|
|
172
|
+
'Narrow the date range, request fewer dimensions/metrics/rows, and pass returnPropertyQuota to see the remaining tokens before retrying.',
|
|
173
|
+
retriable: true,
|
|
174
|
+
account,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (reason === 'dailyLimitExceeded') {
|
|
178
|
+
return {
|
|
179
|
+
error: 'rate_limited',
|
|
180
|
+
message,
|
|
181
|
+
hint: 'A per-day project quota, not a burst limit: it resets at midnight Pacific. Reduce call volume or raise the quota in the Cloud Console; a tight retry loop will not clear it.',
|
|
182
|
+
retriable: true,
|
|
183
|
+
account,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const retryAfter = headerOf(error, 'retry-after');
|
|
187
|
+
return {
|
|
188
|
+
error: 'rate_limited',
|
|
189
|
+
message,
|
|
190
|
+
hint: retryAfter ? `Retry after ${retryAfter}s.` : 'A short-window rate limit. Back off and retry.',
|
|
191
|
+
retriable: true,
|
|
192
|
+
account,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Prefer AIP metadata (service + consumer) over scraping the message: the
|
|
196
|
+
* scrape stays as the fallback because Google's JSON error text is English
|
|
197
|
+
* only, unlike its localized HTML pages. */
|
|
198
|
+
function enableHint(info, message) {
|
|
199
|
+
const service = info?.metadata?.service;
|
|
200
|
+
const project = info?.metadata?.consumer?.replace(/^projects\//, '');
|
|
201
|
+
if (service) {
|
|
202
|
+
const q = project ? `?project=${encodeURIComponent(project)}` : '';
|
|
203
|
+
return `Enable this API for your project: https://console.cloud.google.com/apis/library/${service}${q}`;
|
|
204
|
+
}
|
|
205
|
+
const api = disabledApiId(message);
|
|
206
|
+
return api
|
|
207
|
+
? `Enable this API for your project: ${apiEnableLink(api)}`
|
|
208
|
+
: 'Enable the API for your project at https://console.cloud.google.com/apis/library';
|
|
209
|
+
}
|
|
210
|
+
export function mapGoogleError(error, account, serviceHints, scopeContext) {
|
|
57
211
|
const status = statusOf(error);
|
|
58
|
-
const
|
|
59
|
-
const
|
|
212
|
+
const payload = errorPayload(error);
|
|
213
|
+
const reason = reasonOf(error, payload);
|
|
214
|
+
const message = safeMessage(error, payload, status);
|
|
215
|
+
const hints = normalizeHints(serviceHints);
|
|
60
216
|
// 7-day Testing-mode trap: a BYO OAuth client in "Testing" status expires
|
|
61
217
|
// refresh tokens weekly; the dead token surfaces as invalid_grant. Name the
|
|
62
218
|
// real fix (publish to production) so the agent stops looping on re-auth.
|
|
63
219
|
// Checked before the generic 401 so it wins on either 400 or 401.
|
|
64
|
-
const rawError =
|
|
65
|
-
const grantHaystack = [message, reason, typeof rawError === 'string' ? rawError : '',
|
|
220
|
+
const rawError = payload?.error;
|
|
221
|
+
const grantHaystack = [message, reason, typeof rawError === 'string' ? rawError : '', payload?.error_description]
|
|
66
222
|
.filter(Boolean)
|
|
67
223
|
.join(' ');
|
|
68
224
|
if ((status === 400 || status === 401) && /invalid_grant/i.test(grantHaystack)) {
|
|
@@ -86,61 +242,119 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
|
|
|
86
242
|
};
|
|
87
243
|
}
|
|
88
244
|
if (status === 403) {
|
|
89
|
-
|
|
90
|
-
|
|
245
|
+
const info = errorInfoOf(payload);
|
|
246
|
+
if (reason && FORBIDDEN_RATE_REASONS.has(reason))
|
|
247
|
+
return rateLimited(error, message, account, reason);
|
|
248
|
+
const wrongTool = WRONG_TOOL_HINTS[reason ?? ''];
|
|
249
|
+
if (wrongTool)
|
|
250
|
+
return { error: 'binary_unsupported', message, hint: wrongTool, retriable: false, account };
|
|
91
251
|
const notEnabled = reason === 'accessNotConfigured' ||
|
|
92
252
|
reason === 'SERVICE_DISABLED' ||
|
|
93
253
|
/has not been used in project|accessNotConfigured|SERVICE_DISABLED|it is disabled/i.test(message);
|
|
94
254
|
if (notEnabled) {
|
|
95
|
-
|
|
96
|
-
return {
|
|
97
|
-
error: 'api_not_enabled',
|
|
98
|
-
message,
|
|
99
|
-
hint: api
|
|
100
|
-
? `Enable this API for your project: ${apiEnableLink(api)}`
|
|
101
|
-
: 'Enable the API for your project at https://console.cloud.google.com/apis/library',
|
|
102
|
-
retriable: false,
|
|
103
|
-
account,
|
|
104
|
-
};
|
|
255
|
+
return { error: 'api_not_enabled', message, hint: enableHint(info, message), retriable: false, account };
|
|
105
256
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
257
|
+
// The scope hint fires ONLY on the machine-readable token. A coarse AIP
|
|
258
|
+
// status of PERMISSION_DENIED is equally true of a resource denial, and
|
|
259
|
+
// branching on it is what told callers to add a bundle they already had.
|
|
260
|
+
const scopeIssue = reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' ||
|
|
261
|
+
reason === 'insufficientPermissions' ||
|
|
262
|
+
/\bACCESS_TOKEN_SCOPE_INSUFFICIENT\b/.test(message);
|
|
109
263
|
if (scopeIssue) {
|
|
110
|
-
// #114 three-state enrichment when the method's required scopes are
|
|
111
|
-
// known (escape hatch / generated tools): state-specific remediation and
|
|
112
|
-
// an honest retriable so agents stop retrying dead ends.
|
|
113
264
|
const enriched = scopeContext?.() ?? null;
|
|
114
265
|
return {
|
|
115
266
|
error: 'insufficient_scope',
|
|
116
267
|
message,
|
|
117
|
-
hint: enriched?.hint ??
|
|
268
|
+
hint: enriched?.hint ?? hints.scope ?? `Re-auth "${account}" with the scope this operation needs. ${reauthHint(account)}`,
|
|
118
269
|
retriable: enriched?.retriable ?? false,
|
|
119
270
|
account,
|
|
120
271
|
};
|
|
121
272
|
}
|
|
122
|
-
|
|
273
|
+
if (reason === 'storageQuotaExceeded') {
|
|
274
|
+
return { error: 'forbidden', message, hint: `Drive storage for "${account}" is full. Free space or use another account; retrying will not help.`, retriable: false, account };
|
|
275
|
+
}
|
|
276
|
+
if (reason === 'CONSUMER_SUSPENDED' || reason === 'accountSuspended') {
|
|
277
|
+
return { error: 'forbidden', message, hint: `Google has suspended the project or the account behind "${account}". Check the Cloud Console notifications; no change to the request will fix this.`, retriable: false, account };
|
|
278
|
+
}
|
|
279
|
+
if (reason === 'domainPolicy' || reason === 'ORG_RESTRICTION_VIOLATION') {
|
|
280
|
+
return { error: 'forbidden', message, hint: `A Workspace admin policy blocks this operation for the domain of "${account}". A super-admin has to allow the app or the feature; re-auth will not change it.`, retriable: false, account };
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
error: 'forbidden',
|
|
284
|
+
message,
|
|
285
|
+
hint: hints.resource ??
|
|
286
|
+
`Google denied access to this resource for "${account}". Check that the item exists and is shared with this account, that you picked the right account alias, and for Workspace items that no admin policy blocks it. If a missing scope is also possible, run diagnose.`,
|
|
287
|
+
retriable: false,
|
|
288
|
+
account,
|
|
289
|
+
};
|
|
123
290
|
}
|
|
124
291
|
if (status === 400 && /invalid[_ ]scope/i.test(message)) {
|
|
125
|
-
return {
|
|
292
|
+
return {
|
|
293
|
+
error: 'invalid_scope',
|
|
294
|
+
message,
|
|
295
|
+
hint: 'One of the requested OAuth scopes is malformed or unavailable to this client. Run `config check` to review the account scope profile, fix it, then re-auth.',
|
|
296
|
+
retriable: false,
|
|
297
|
+
account,
|
|
298
|
+
};
|
|
126
299
|
}
|
|
127
300
|
if (status === 404) {
|
|
128
|
-
return {
|
|
301
|
+
return {
|
|
302
|
+
error: 'not_found',
|
|
303
|
+
message,
|
|
304
|
+
hint: `The ID does not exist or is not visible to "${account}". IDs are account-specific: re-fetch it with the matching list/search tool, and check the account alias is the one that owns the resource.`,
|
|
305
|
+
retriable: false,
|
|
306
|
+
account,
|
|
307
|
+
};
|
|
129
308
|
}
|
|
130
|
-
if (status === 429)
|
|
131
|
-
|
|
309
|
+
if (status === 429)
|
|
310
|
+
return rateLimited(error, message, account, reason);
|
|
311
|
+
if (status !== undefined && status >= 500) {
|
|
132
312
|
return {
|
|
133
|
-
error: '
|
|
313
|
+
error: 'upstream_error',
|
|
134
314
|
message,
|
|
135
|
-
hint:
|
|
315
|
+
hint: 'Google-side server error, usually transient: retry, with backoff if it repeats.',
|
|
136
316
|
retriable: true,
|
|
137
317
|
account,
|
|
138
318
|
};
|
|
139
319
|
}
|
|
140
|
-
|
|
141
|
-
|
|
320
|
+
// A caller-side 4xx and a Google outage used to share `upstream_error`, so a
|
|
321
|
+
// consumer keying on the slug could not tell "your request is wrong, never
|
|
322
|
+
// retry" from "Google is having a bad minute, do retry".
|
|
323
|
+
if (status !== undefined && status >= 400) {
|
|
324
|
+
return {
|
|
325
|
+
error: 'bad_request',
|
|
326
|
+
message,
|
|
327
|
+
hint: status === 400
|
|
328
|
+
? 'Google parsed the request and rejected it: an argument is likely wrong or missing. Check IDs, enum values and formats against the tool description before retrying.'
|
|
329
|
+
: `Google rejected the request with HTTP ${status}: a conflict, a failed precondition or an unsupported operation. Change the request rather than repeating it.`,
|
|
330
|
+
retriable: false,
|
|
331
|
+
account,
|
|
332
|
+
};
|
|
142
333
|
}
|
|
143
334
|
if (status === undefined) {
|
|
335
|
+
// The server's own preconditions, tagged at the throw site in client.ts.
|
|
336
|
+
// Untagged they reached the floor and were reported as Google failures.
|
|
337
|
+
if (error?.code === 'E_NO_TOKEN') {
|
|
338
|
+
return { error: 'auth_required', message, hint: reauthHint(account), retriable: false, account };
|
|
339
|
+
}
|
|
340
|
+
if (error?.code === 'E_NO_OAUTH_CLIENT') {
|
|
341
|
+
return {
|
|
342
|
+
error: 'invalid_client',
|
|
343
|
+
message,
|
|
344
|
+
hint: 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (see docs/google-cloud-setup.md), then restart the server.',
|
|
345
|
+
retriable: false,
|
|
346
|
+
account,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (error?.code === 'E_UNKNOWN_ACCOUNT') {
|
|
350
|
+
return {
|
|
351
|
+
error: 'validation_error',
|
|
352
|
+
message,
|
|
353
|
+
hint: 'Pass one of the configured aliases, or omit account to use the default. Call account_list to see them.',
|
|
354
|
+
retriable: false,
|
|
355
|
+
account,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
144
358
|
const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
|
|
145
359
|
if (fsCode) {
|
|
146
360
|
const p = typeof error?.path === 'string' ? ` "${error.path}"` : '';
|
|
@@ -165,12 +379,62 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
|
|
|
165
379
|
};
|
|
166
380
|
}
|
|
167
381
|
}
|
|
168
|
-
|
|
382
|
+
// A gaxios-shaped error carries `response` or `config`, so the request left
|
|
383
|
+
// this process. Anything else threw INSIDE the server (a TypeError in a
|
|
384
|
+
// handler, a missing key), and calling that `upstream_error` blamed Google
|
|
385
|
+
// for our own bug: an empty messageId used to surface exactly that way.
|
|
386
|
+
if (error?.response !== undefined || error?.config !== undefined) {
|
|
387
|
+
return {
|
|
388
|
+
error: 'upstream_error',
|
|
389
|
+
message,
|
|
390
|
+
hint: 'The request left this server but no HTTP status came back. Retry once; if it repeats, check connectivity and the Google status dashboard.',
|
|
391
|
+
retriable: false,
|
|
392
|
+
account,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
error: 'internal',
|
|
397
|
+
message,
|
|
398
|
+
hint: 'This failed inside the MCP server, before any request was sent. The message above is the signal: it is most often a configuration problem such as a missing key or credential. Retrying the same call will fail the same way.',
|
|
399
|
+
retriable: false,
|
|
400
|
+
account,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/** Bound the SERIALIZED envelope, not just the message: an authored hint can
|
|
404
|
+
* be long too, and the error path had no cap at all before this. */
|
|
405
|
+
export function stringifyEnvelope(envelope) {
|
|
406
|
+
let out = { ...envelope };
|
|
407
|
+
let text = JSON.stringify(out);
|
|
408
|
+
if (text.length <= MAX_ERROR_ENVELOPE_CHARS)
|
|
409
|
+
return text;
|
|
410
|
+
const msgBudget = JSON.stringify(out.message).length - (text.length - MAX_ERROR_ENVELOPE_CHARS);
|
|
411
|
+
out = {
|
|
412
|
+
...out,
|
|
413
|
+
message: msgBudget < 40 ? '(message dropped: envelope size cap)' : sliceEncoded(out.message, msgBudget),
|
|
414
|
+
};
|
|
415
|
+
text = JSON.stringify(out);
|
|
416
|
+
if (text.length <= MAX_ERROR_ENVELOPE_CHARS || typeof out.hint !== 'string')
|
|
417
|
+
return text;
|
|
418
|
+
const hintBudget = JSON.stringify(out.hint).length - (text.length - MAX_ERROR_ENVELOPE_CHARS);
|
|
419
|
+
out = { ...out, hint: hintBudget < 40 ? '(hint dropped: envelope size cap)' : sliceEncoded(out.hint, hintBudget) };
|
|
420
|
+
return JSON.stringify(out);
|
|
421
|
+
}
|
|
422
|
+
export function handleGoogleApiError(error, account, serviceHints, scopeContext) {
|
|
423
|
+
const envelope = mapGoogleError(error, account, serviceHints, scopeContext);
|
|
424
|
+
return {
|
|
425
|
+
content: [{ type: 'text', text: stringifyEnvelope(envelope) }],
|
|
426
|
+
isError: true,
|
|
427
|
+
};
|
|
169
428
|
}
|
|
170
|
-
|
|
171
|
-
|
|
429
|
+
/** Local argument rejection, before any Google call. Same envelope as
|
|
430
|
+
* mapGoogleError so a caller parses one shape. The slug literal stays inline
|
|
431
|
+
* because the KNOWN_ERROR_SLUGS honesty test greps for `error: '<slug>'`. */
|
|
432
|
+
export function invalidParams(account, message, hint) {
|
|
172
433
|
return {
|
|
173
|
-
content: [{
|
|
434
|
+
content: [{
|
|
435
|
+
type: 'text',
|
|
436
|
+
text: stringifyEnvelope({ error: 'invalid_params', message, hint, retriable: false, account }),
|
|
437
|
+
}],
|
|
174
438
|
isError: true,
|
|
175
439
|
};
|
|
176
440
|
}
|
|
@@ -2,8 +2,13 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
// path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
|
|
4
4
|
export function prepareLocalDest(savePath, filename) {
|
|
5
|
-
const
|
|
6
|
-
|
|
5
|
+
const name = path.basename(filename);
|
|
6
|
+
// Agents routinely pass the intended FILE path as savePath and repeat the
|
|
7
|
+
// name in `filename`; a blind join would mkdir a directory named like the
|
|
8
|
+
// file and bury the download inside it, so strip the duplicated leaf.
|
|
9
|
+
const dir = path.basename(savePath) === name ? path.dirname(savePath) : savePath;
|
|
10
|
+
const dest = path.join(dir, name);
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
7
12
|
return dest;
|
|
8
13
|
}
|
|
9
14
|
// fs.createReadStream() reports an unopenable path as an async 'error' EVENT;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpServer } from
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
2
|
import type { ToolRegistry } from '../registry.js';
|
|
3
3
|
export interface AddForm {
|
|
4
4
|
alias: string;
|
|
@@ -23,10 +23,33 @@ export type AddValidation = {
|
|
|
23
23
|
ok: false;
|
|
24
24
|
slug: string;
|
|
25
25
|
message: string;
|
|
26
|
+
hint?: string;
|
|
27
|
+
alias?: string;
|
|
26
28
|
};
|
|
27
29
|
/** Validate the collected form against the alias rules, dup check, and the
|
|
28
30
|
* bundle catalog. Pure (no I/O) for unit testing. */
|
|
29
31
|
export declare function validateAddForm(input: Partial<AddForm>, existingAliases: string[]): AddValidation;
|
|
32
|
+
/** URL-mode elicitation params. `elicitationId` is REQUIRED by the spec
|
|
33
|
+
* schema; omitting it made every url-mode request fail client-side
|
|
34
|
+
* validation, so the branch silently never worked. Pure, so the shape is
|
|
35
|
+
* testable against the SDK's own schema without a live client. */
|
|
36
|
+
export declare function urlElicitationParams(alias: string, url: string, elicitationId: string): {
|
|
37
|
+
mode: 'url';
|
|
38
|
+
elicitationId: string;
|
|
39
|
+
message: string;
|
|
40
|
+
url: string;
|
|
41
|
+
};
|
|
42
|
+
/** Map direct tool arguments onto the elicitation form shape: the argument-
|
|
43
|
+
* mode fallback for clients without form elicitation. All bundle picks travel
|
|
44
|
+
* through otherBundles, which validateAddForm resolves and validates. Pure. */
|
|
45
|
+
export declare function argsToAddForm(a: {
|
|
46
|
+
alias?: string;
|
|
47
|
+
account?: string;
|
|
48
|
+
email?: string;
|
|
49
|
+
bundles?: string;
|
|
50
|
+
allBundles?: boolean;
|
|
51
|
+
admin?: boolean;
|
|
52
|
+
}): Partial<AddForm>;
|
|
30
53
|
/** Scopes requested by the profile but NOT granted at consent (granular
|
|
31
54
|
* consent / unchecked bundles). Pure. */
|
|
32
55
|
export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
|