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.
Files changed (103) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +5 -0
  3. package/dist/accounts.js +12 -1
  4. package/dist/api-probe.js +3 -0
  5. package/dist/arg-normalize.d.ts +61 -4
  6. package/dist/arg-normalize.js +228 -4
  7. package/dist/arg-strict.d.ts +54 -0
  8. package/dist/arg-strict.js +163 -0
  9. package/dist/auth.js +15 -72
  10. package/dist/client.js +15 -5
  11. package/dist/config-file.d.ts +1 -0
  12. package/dist/config-file.js +4 -0
  13. package/dist/discover.js +35 -14
  14. package/dist/discovery-client.d.ts +8 -1
  15. package/dist/discovery-client.js +96 -17
  16. package/dist/doctor.d.ts +9 -1
  17. package/dist/doctor.js +32 -4
  18. package/dist/env-load.d.ts +11 -0
  19. package/dist/env-load.js +23 -1
  20. package/dist/executor.js +12 -1
  21. package/dist/fanout.js +43 -8
  22. package/dist/http-transport.d.ts +11 -2
  23. package/dist/http-transport.js +10 -5
  24. package/dist/index.js +112 -12
  25. package/dist/metrics-cli.d.ts +21 -0
  26. package/dist/metrics-cli.js +206 -0
  27. package/dist/metrics-tap.d.ts +12 -0
  28. package/dist/metrics-tap.js +95 -0
  29. package/dist/oauth-consent.d.ts +25 -10
  30. package/dist/oauth-consent.js +85 -39
  31. package/dist/outbound-allowlist.d.ts +34 -0
  32. package/dist/outbound-allowlist.js +108 -0
  33. package/dist/registry.d.ts +49 -2
  34. package/dist/registry.js +147 -7
  35. package/dist/scope-catalog.js +16 -0
  36. package/dist/services.d.ts +8 -0
  37. package/dist/services.js +28 -1
  38. package/dist/setup-prompt.d.ts +1 -1
  39. package/dist/setup-prompt.js +1 -1
  40. package/dist/tools/_coerce.d.ts +5 -0
  41. package/dist/tools/_coerce.js +11 -0
  42. package/dist/tools/_errors.d.ts +31 -3
  43. package/dist/tools/_errors.js +307 -43
  44. package/dist/tools/_local-files.js +7 -2
  45. package/dist/tools/account-wizard.d.ts +24 -1
  46. package/dist/tools/account-wizard.js +152 -51
  47. package/dist/tools/admin.js +7 -6
  48. package/dist/tools/analytics.d.ts +18 -0
  49. package/dist/tools/analytics.js +284 -0
  50. package/dist/tools/calendar.d.ts +7 -0
  51. package/dist/tools/calendar.js +75 -15
  52. package/dist/tools/chat.js +7 -4
  53. package/dist/tools/contacts.js +57 -30
  54. package/dist/tools/docs.js +56 -54
  55. package/dist/tools/drive.js +132 -70
  56. package/dist/tools/forms.js +7 -7
  57. package/dist/tools/generated/_shared.d.ts +6 -0
  58. package/dist/tools/generated/_shared.js +11 -1
  59. package/dist/tools/generated/admin.js +345 -214
  60. package/dist/tools/generated/analytics.d.ts +2 -0
  61. package/dist/tools/generated/analytics.js +981 -0
  62. package/dist/tools/generated/appsmarket.js +4 -4
  63. package/dist/tools/generated/calendar.js +28 -28
  64. package/dist/tools/generated/chat.js +83 -56
  65. package/dist/tools/generated/classroom.js +258 -216
  66. package/dist/tools/generated/cloudidentity.js +76 -68
  67. package/dist/tools/generated/cloudsearch.js +40 -39
  68. package/dist/tools/generated/contacts.js +20 -11
  69. package/dist/tools/generated/drive.js +62 -53
  70. package/dist/tools/generated/drivelabels.js +60 -32
  71. package/dist/tools/generated/forms.js +7 -7
  72. package/dist/tools/generated/gmail.js +136 -109
  73. package/dist/tools/generated/groupsmigration.js +1 -1
  74. package/dist/tools/generated/groupssettings.js +3 -3
  75. package/dist/tools/generated/index.js +2 -0
  76. package/dist/tools/generated/keep.js +8 -7
  77. package/dist/tools/generated/licensing.js +39 -23
  78. package/dist/tools/generated/meet.js +14 -14
  79. package/dist/tools/generated/method-map.d.ts +2 -0
  80. package/dist/tools/generated/method-map.js +894 -0
  81. package/dist/tools/generated/postmaster.js +3 -3
  82. package/dist/tools/generated/reseller.js +34 -28
  83. package/dist/tools/generated/script.js +29 -19
  84. package/dist/tools/generated/searchconsole.js +5 -3
  85. package/dist/tools/generated/sheets.js +10 -9
  86. package/dist/tools/generated/tasks.js +10 -4
  87. package/dist/tools/generated/vault.js +65 -56
  88. package/dist/tools/generated/workspaceevents.js +17 -16
  89. package/dist/tools/gmail.d.ts +10 -1
  90. package/dist/tools/gmail.js +50 -18
  91. package/dist/tools/google-api.js +48 -19
  92. package/dist/tools/meet.js +5 -2
  93. package/dist/tools/searchconsole.js +10 -10
  94. package/dist/tools/sheets.js +36 -48
  95. package/dist/tools/slides.js +6 -6
  96. package/dist/tools/tasks.js +16 -16
  97. package/dist/trim.d.ts +21 -0
  98. package/dist/trim.js +47 -0
  99. package/dist/usage-metrics.d.ts +155 -0
  100. package/dist/usage-metrics.js +603 -0
  101. package/dist/write-control.d.ts +27 -1
  102. package/dist/write-control.js +90 -11
  103. package/package.json +8 -3
@@ -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
- function reasonOf(error) {
8
- return (error?.errors?.[0]?.reason ??
9
- error?.response?.data?.error?.errors?.[0]?.reason ??
10
- error?.response?.data?.error?.status);
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
- function messageOf(error) {
13
- return error?.response?.data?.error?.message ?? error?.message ?? String(error);
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
- export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
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 reason = reasonOf(error);
59
- const message = messageOf(error);
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 = error?.response?.data?.error;
65
- const grantHaystack = [message, reason, typeof rawError === 'string' ? rawError : '', error?.response?.data?.error_description]
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
- // API not enabled for the project: a distinct, self-serve fix (enable the
90
- // API) rather than a scope/permission dead-end.
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
- const api = disabledApiId(message);
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
- const scopeIssue = reason === 'insufficientPermissions' ||
107
- reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' ||
108
- /insufficient.*scope/i.test(message);
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 ?? forbiddenHint ?? `Re-auth "${account}" with the scope this operation needs.`,
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
- return { error: 'forbidden', message, hint: forbiddenHint, retriable: false, account };
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 { error: 'invalid_scope', message, retriable: false, account };
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 { error: 'not_found', message, retriable: false, account };
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
- const retryAfter = error?.response?.headers?.['retry-after'];
309
+ if (status === 429)
310
+ return rateLimited(error, message, account, reason);
311
+ if (status !== undefined && status >= 500) {
132
312
  return {
133
- error: 'rate_limited',
313
+ error: 'upstream_error',
134
314
  message,
135
- hint: retryAfter ? `Retry after ${retryAfter}s.` : 'Back off and retry.',
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
- if (status !== undefined && status >= 500) {
141
- return { error: 'upstream_error', message, retriable: true, account };
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
- return { error: 'upstream_error', message, retriable: false, account };
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
- export function handleGoogleApiError(error, account, forbiddenHint, scopeContext) {
171
- const envelope = mapGoogleError(error, account, forbiddenHint, scopeContext);
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: [{ type: 'text', text: JSON.stringify(envelope) }],
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 dest = path.join(savePath, path.basename(filename));
6
- fs.mkdirSync(savePath, { recursive: true });
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 '@modelcontextprotocol/sdk/server/mcp.js';
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[];