mcp-google-multi 6.0.0-alpha.68 → 6.0.0-alpha.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
Binary file
package/dist/discover.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
+ import { hints } from './hint-provider.js';
3
4
  // The description budget is the whole point of lazy mode: a full op list per
4
5
  // service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
5
6
  // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
@@ -91,6 +92,7 @@ export function registerDiscoverTools(registry, policy) {
91
92
  }, async ({ query }) => {
92
93
  const operations = registry.catalog(service, query);
93
94
  registry.reveal(service);
95
+ const rendered = hints()?.render({ kind: 'policy_summary', policy });
94
96
  return {
95
97
  content: [
96
98
  {
@@ -98,7 +100,7 @@ export function registerDiscoverTools(registry, policy) {
98
100
  text: JSON.stringify({
99
101
  service,
100
102
  operations,
101
- writeControl: describePolicy(policy),
103
+ writeControl: rendered?.hint ?? describePolicy(policy),
102
104
  next: operations.length > 0
103
105
  ? 'Call the chosen tool by name; it is now listed and callable.'
104
106
  : `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
package/dist/doctor.js CHANGED
@@ -14,6 +14,7 @@ import { apiProbeDepsFor, probeApiEnablement } from './api-probe.js';
14
14
  import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
15
15
  import { resolveHttpConfig, HttpConfigError } from './http-config.js';
16
16
  import { parseOwnerEmails } from './http-transport.js';
17
+ import { hints } from './hint-provider.js';
17
18
  const MIN_NODE_MAJOR = 22;
18
19
  const DEFAULT_DEPS = {
19
20
  nodeVersion: process.versions.node,
@@ -70,9 +71,22 @@ export function apiEnableLink(api) {
70
71
  return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
71
72
  }
72
73
  const LEGACY_ENV_KEYS = ['GOOGLE_ACCOUNTS', 'GOOGLE_OPTIONAL_SCOPES', 'GOOGLE_ADMIN_ACCOUNTS'];
73
- function sectionRuntime(deps) {
74
+ function sectionRuntime(deps, tenantScope = false) {
74
75
  const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
75
76
  if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
77
+ if (tenantScope) {
78
+ // The host version is operator infrastructure, not the tenant's (E7
79
+ // residue): no version string, and the fix is the operator's to apply.
80
+ const rendered = hints()?.render({ kind: 'operator_only', slug: 'E_NODE_TOO_OLD' });
81
+ return {
82
+ id: 1,
83
+ title: 'Runtime',
84
+ verdict: 'fail',
85
+ slug: 'E_NODE_TOO_OLD',
86
+ lines: ['Runtime below the supported version'],
87
+ hint: rendered?.hint ?? 'Tell the server operator the code E_NODE_TOO_OLD.',
88
+ };
89
+ }
76
90
  return {
77
91
  id: 1,
78
92
  title: 'Runtime',
@@ -82,7 +96,12 @@ function sectionRuntime(deps) {
82
96
  hint: `Upgrade to Node ${MIN_NODE_MAJOR} LTS or newer.`,
83
97
  };
84
98
  }
85
- return { id: 1, title: 'Runtime', verdict: 'ok', lines: [`Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`] };
99
+ return {
100
+ id: 1,
101
+ title: 'Runtime',
102
+ verdict: 'ok',
103
+ lines: [tenantScope ? 'Runtime ok' : `Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`],
104
+ };
86
105
  }
87
106
  function sectionConfig(deps, set, tenantScope = false) {
88
107
  const lines = [];
@@ -90,13 +109,16 @@ function sectionConfig(deps, set, tenantScope = false) {
90
109
  let hint;
91
110
  let slug;
92
111
  if (!set || set.aliases.length === 0) {
112
+ const tenantHint = tenantScope ? (hints()?.render({ kind: 'no_accounts' })?.hint ?? '') : undefined;
93
113
  return {
94
114
  id: 2,
95
115
  title: 'Config',
96
116
  verdict: 'fail',
97
117
  slug: 'E_NO_ACCOUNTS_CONFIGURED',
98
118
  lines: ['No accounts configured.'],
99
- ...(tenantScope ? {} : { hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.' }),
119
+ ...(tenantScope
120
+ ? (tenantHint ? { hint: tenantHint } : {})
121
+ : { hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.' }),
100
122
  };
101
123
  }
102
124
  // The config file, process env, working directory and metrics store below
@@ -188,7 +210,8 @@ function sectionsTokensAndScopes(deps, aliases) {
188
210
  if (scopeVerdict === 'ok')
189
211
  scopeVerdict = 'warn';
190
212
  scopeLines.push(`${alias}: ${r.callable.length} callable, ${r.requestable.length} requested-not-granted`);
191
- scopeHint = `Re-auth to grant missing scopes. ${reauthHint(alias)}`;
213
+ const rendered = hints()?.render({ kind: 'scopes_missing', account: alias });
214
+ scopeHint = rendered?.hint ?? `Re-auth to grant missing scopes. ${reauthHint(alias)}`;
192
215
  }
193
216
  else {
194
217
  scopeLines.push(`${alias}: ${r.callable.length} callable, all profile scopes granted`);
@@ -199,7 +222,7 @@ function sectionsTokensAndScopes(deps, aliases) {
199
222
  { id: 5, title: 'Scopes', verdict: scopeVerdict, lines: scopeLines, ...(scopeHint ? { hint: scopeHint } : {}), ...(scopeVerdict === 'warn' ? { slug: 'E_SCOPE_NOT_GRANTED' } : {}) },
200
223
  ];
201
224
  }
202
- async function sectionApiEnablement(deps, aliases) {
225
+ async function sectionApiEnablement(deps, aliases, tenantScope = false) {
203
226
  if (!deps.probeApi) {
204
227
  return { id: 6, title: 'API enablement', verdict: 'unknown', lines: ['Probe not run (no live account probe configured).'] };
205
228
  }
@@ -216,8 +239,15 @@ async function sectionApiEnablement(deps, aliases) {
216
239
  results = await deps.probeApi(healthy);
217
240
  }
218
241
  catch (e) {
219
- // Network / transient: WARN with the target, never crash the report.
220
- return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
242
+ // Network / transient: WARN with the target, never crash the report. The
243
+ // raw text can name an egress or proxy host (E7 residue): tenant scope
244
+ // drops it.
245
+ return {
246
+ id: 6,
247
+ title: 'API enablement',
248
+ verdict: 'warn',
249
+ lines: [tenantScope ? 'Probe could not complete.' : `Probe could not complete: ${e?.message ?? e}`],
250
+ };
221
251
  }
222
252
  if (results.length === 0) {
223
253
  return { id: 6, title: 'API enablement', verdict: 'unknown', lines: [`No probeable service scopes granted on "${healthy}".`] };
@@ -225,13 +255,14 @@ async function sectionApiEnablement(deps, aliases) {
225
255
  const disabled = results.filter((r) => r.notEnabled);
226
256
  const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
227
257
  if (disabled.length > 0) {
258
+ const rendered = hints()?.render({ kind: 'apis_disabled', services: disabled.map((r) => ({ service: r.service, api: r.api })) });
228
259
  return {
229
260
  id: 6,
230
261
  title: 'API enablement',
231
262
  verdict: 'fail',
232
263
  slug: 'E_API_NOT_ENABLED',
233
264
  lines,
234
- hint: disabled.map((r) => `Enable ${r.service}: ${apiEnableLink(r.api)}`).join('\n'),
265
+ hint: rendered?.hint ?? disabled.map((r) => `Enable ${r.service}: ${apiEnableLink(r.api)}`).join('\n'),
235
266
  };
236
267
  }
237
268
  return { id: 6, title: 'API enablement', verdict: 'ok', lines: lines.length ? lines : ['(probed account, all enabled)'] };
@@ -313,7 +344,7 @@ export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
313
344
  // the context's own account count.
314
345
  const tenantScope = opts.scope === 'tenant';
315
346
  const sections = [];
316
- sections.push(sectionRuntime(deps));
347
+ sections.push(sectionRuntime(deps, tenantScope));
317
348
  const set = deps.accountSet();
318
349
  sections.push(sectionConfig(deps, set, tenantScope));
319
350
  const aliases = set?.aliases ?? [];
@@ -322,7 +353,7 @@ export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
322
353
  if (aliases.length > 0) {
323
354
  const [tokens, scopes] = sectionsTokensAndScopes(deps, aliases);
324
355
  sections.push(tokens, scopes);
325
- sections.push(await sectionApiEnablement(deps, aliases));
356
+ sections.push(await sectionApiEnablement(deps, aliases, tenantScope));
326
357
  }
327
358
  if (!tenantScope) {
328
359
  const http = await sectionHttp(deps, aliases);
package/dist/executor.js CHANGED
@@ -4,6 +4,7 @@ import { expandPath, isGoogleApiUrl } from './discovery-client.js';
4
4
  import { handleGoogleApiError } from './tools/_errors.js';
5
5
  import { scopeHintForMethod } from './scope-observability.js';
6
6
  import { checkOutboundForMethod } from './outbound-allowlist.js';
7
+ import { hints } from './hint-provider.js';
7
8
  export const MAX_RESPONSE_CHARS = 100_000;
8
9
  export function jsonResult(payload, isError = false) {
9
10
  const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -70,10 +71,11 @@ export async function executeApiMethod(method, args, deps = {}) {
70
71
  }, true);
71
72
  }
72
73
  if (!isGoogleApiUrl(url)) {
74
+ const rendered = hints()?.render({ kind: 'untrusted_host' });
73
75
  return jsonResult({
74
76
  error: 'untrusted_host',
75
77
  message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
76
- hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
78
+ hint: rendered?.hint ?? 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
77
79
  retriable: false,
78
80
  account: args.account,
79
81
  }, true);
package/dist/fanout.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { getAccountSet, liveAccountCheck, unknownAliasMessage } from './accounts.js';
3
3
  import { echoName, echoNames, MAX_SUGGESTED_KEYS } from './arg-strict.js';
4
+ import { hints } from './hint-provider.js';
4
5
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
5
6
  const FANOUT_CONCURRENCY = 5;
6
7
  // `aliases` is required (no global default): the field is baked into a
@@ -113,10 +114,17 @@ export async function runFanout(handler, args, aliases) {
113
114
  results[i] = res?.isError ? { account, ok: false, error: payload } : { account, ok: true, data: payload };
114
115
  }
115
116
  catch (err) {
117
+ const rendered = hints()?.render({ kind: 'internal', account });
116
118
  results[i] = {
117
119
  account,
118
120
  ok: false,
119
- error: { error: 'internal', message: err.message, retriable: false, account },
121
+ error: {
122
+ error: 'internal',
123
+ message: rendered?.message ?? err.message,
124
+ hint: rendered?.hint,
125
+ retriable: false,
126
+ account,
127
+ },
120
128
  };
121
129
  }
122
130
  }
@@ -0,0 +1,129 @@
1
+ import type { DenyReason, Policy } from './write-control.js';
2
+ export type HintCase = {
3
+ kind: 'reauth';
4
+ account: string;
5
+ } | {
6
+ kind: 'legacy_plaintext_token';
7
+ account: string;
8
+ } | {
9
+ kind: 'dead_refresh_token';
10
+ account: string;
11
+ } | {
12
+ kind: 'token_unreadable';
13
+ account: string;
14
+ } | {
15
+ kind: 'scopes_missing';
16
+ account: string;
17
+ } | {
18
+ kind: 'scope_not_granted';
19
+ account: string;
20
+ scope: string;
21
+ } | {
22
+ kind: 'scope_needs_bundle';
23
+ account: string;
24
+ scope: string;
25
+ bundle: string;
26
+ } | {
27
+ kind: 'scope_base_excluded';
28
+ account: string;
29
+ scope: string;
30
+ } | {
31
+ kind: 'scope_account_type';
32
+ account: string;
33
+ scope: string;
34
+ } | {
35
+ kind: 'scope_unknown_scope';
36
+ scope: string;
37
+ } | {
38
+ kind: 'scope_unknown';
39
+ account: string;
40
+ serviceText?: string;
41
+ bundle?: string;
42
+ } | {
43
+ kind: 'write_denied';
44
+ tool: {
45
+ name: string;
46
+ service: string;
47
+ cud: string;
48
+ };
49
+ reason: DenyReason;
50
+ policy: Policy;
51
+ } | {
52
+ kind: 'policy_summary';
53
+ policy: Policy;
54
+ } | {
55
+ kind: 'outbound_denied';
56
+ what: string;
57
+ blocked: string[];
58
+ entries: string[];
59
+ } | {
60
+ kind: 'no_default_account';
61
+ aliases: string[];
62
+ } | {
63
+ kind: 'no_accounts';
64
+ } | {
65
+ kind: 'api_not_enabled';
66
+ api?: string;
67
+ service?: string;
68
+ project?: string;
69
+ googleMessage: string;
70
+ } | {
71
+ kind: 'apis_disabled';
72
+ services: {
73
+ service: string;
74
+ api: string;
75
+ }[];
76
+ } | {
77
+ kind: 'daily_quota';
78
+ } | {
79
+ kind: 'project_suspended';
80
+ account: string;
81
+ } | {
82
+ kind: 'invalid_scope';
83
+ account: string;
84
+ } | {
85
+ kind: 'no_oauth_client';
86
+ } | {
87
+ kind: 'network_error';
88
+ code: string;
89
+ } | {
90
+ kind: 'local_path';
91
+ code: string;
92
+ path?: string;
93
+ } | {
94
+ kind: 'internal';
95
+ account?: string;
96
+ } | {
97
+ kind: 'untrusted_host';
98
+ } | {
99
+ kind: 'toolset_disabled';
100
+ api: string;
101
+ service: string;
102
+ } | {
103
+ kind: 'service_not_enabled';
104
+ tool: string;
105
+ service: string;
106
+ bundle?: string;
107
+ aliases: string[];
108
+ admin: boolean;
109
+ toolsetOff: boolean;
110
+ awaitingRestart: boolean;
111
+ } | {
112
+ kind: 'operator_only';
113
+ slug: string;
114
+ };
115
+ export declare const HINT_KINDS: readonly ["reauth", "legacy_plaintext_token", "dead_refresh_token", "token_unreadable", "scopes_missing", "scope_not_granted", "scope_needs_bundle", "scope_base_excluded", "scope_account_type", "scope_unknown_scope", "scope_unknown", "write_denied", "policy_summary", "outbound_denied", "no_default_account", "no_accounts", "api_not_enabled", "apis_disabled", "daily_quota", "project_suspended", "invalid_scope", "no_oauth_client", "network_error", "local_path", "internal", "untrusted_host", "toolset_disabled", "service_not_enabled", "operator_only"];
116
+ /** `message` only for the kinds whose message leaks today (write_denied,
117
+ * no_oauth_client, local_path, internal, toolset_disabled); every other call
118
+ * site ignores it. `retriable` only where it may legitimately differ from
119
+ * the owner's default (scope_not_granted). */
120
+ export interface Rendered {
121
+ hint: string;
122
+ message?: string;
123
+ retriable?: boolean;
124
+ }
125
+ export interface HintProvider {
126
+ render(c: HintCase): Rendered;
127
+ }
128
+ export declare function setHintProvider(p: HintProvider | null): void;
129
+ export declare function hints(): HintProvider | null;
@@ -0,0 +1,64 @@
1
+ // A plain array literal, not derived from the union: deriving it would need a
2
+ // value-level mirror of the type anyway, and this way a missing member is
3
+ // caught by the completeness check below instead of silently compiling.
4
+ // `as const` so that check narrows to the literal tuple rather than widening
5
+ // every element back to the whole HintCase['kind'] union (which would make
6
+ // it pass trivially, whatever was listed).
7
+ export const HINT_KINDS = [
8
+ 'reauth',
9
+ 'legacy_plaintext_token',
10
+ 'dead_refresh_token',
11
+ 'token_unreadable',
12
+ 'scopes_missing',
13
+ 'scope_not_granted',
14
+ 'scope_needs_bundle',
15
+ 'scope_base_excluded',
16
+ 'scope_account_type',
17
+ 'scope_unknown_scope',
18
+ 'scope_unknown',
19
+ 'write_denied',
20
+ 'policy_summary',
21
+ 'outbound_denied',
22
+ 'no_default_account',
23
+ 'no_accounts',
24
+ 'api_not_enabled',
25
+ 'apis_disabled',
26
+ 'daily_quota',
27
+ 'project_suspended',
28
+ 'invalid_scope',
29
+ 'no_oauth_client',
30
+ 'network_error',
31
+ 'local_path',
32
+ 'internal',
33
+ 'untrusted_host',
34
+ 'toolset_disabled',
35
+ 'service_not_enabled',
36
+ 'operator_only',
37
+ ];
38
+ const _hintKindsComplete = true;
39
+ void _hintKindsComplete;
40
+ // Process-global, like the HTTP reauth linker: one process is either the
41
+ // single owner or one multi-tenant host, never both.
42
+ let installed = null;
43
+ export function setHintProvider(p) {
44
+ installed = p;
45
+ }
46
+ // A throwing provider must never take down the call site: every call site
47
+ // does `hints()?.render(...)?.hint ?? <default>`, so a render that cannot
48
+ // produce a Rendered falls back to the same literal a missing provider
49
+ // would, and the provider's own error text never reaches a client.
50
+ export function hints() {
51
+ if (!installed)
52
+ return null;
53
+ const provider = installed;
54
+ return {
55
+ render(c) {
56
+ try {
57
+ return provider.render(c);
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ },
63
+ };
64
+ }
@@ -42,7 +42,17 @@ export interface HttpHostOptions {
42
42
  * registry, never another subject's. */
43
43
  resolveServer?: (claims: {
44
44
  sub: string;
45
- }) => Promise<ServerTarget | null> | ServerTarget | null;
45
+ }) => Promise<ServerTarget | null | {
46
+ refused: {
47
+ error: string;
48
+ message: string;
49
+ };
50
+ }> | ServerTarget | null | {
51
+ refused: {
52
+ error: string;
53
+ message: string;
54
+ };
55
+ };
46
56
  /** Extra routes keyed by exact pathname (AS endpoints mount here in B13). */
47
57
  routes?: Record<string, RouteHandler>;
48
58
  log?: (line: string) => void;
@@ -16,6 +16,24 @@ const DISPATCH_TIMEOUT_DEFAULT = 120_000;
16
16
  * of a batch synchronously in one tick: without a cap one body multiplies the
17
17
  * per-call work by however many frames fit in it. */
18
18
  const MAX_BATCH_FRAMES = 16;
19
+ // A resolveServer refusal's body reaches the wire verbatim (H3 PR1): validated
20
+ // so a host cannot accidentally leak an operator path/env var through a wrongly
21
+ // shaped slug or an overlong message. No new slug literal enters core itself —
22
+ // the slug is always the host's own value.
23
+ const REFUSAL_SLUG_RE = /^[a-z][a-z0-9_]{0,39}$/;
24
+ const MAX_REFUSAL_MESSAGE_CHARS = 300;
25
+ // C0/C1 controls, format chars (includes bidi overrides like U+202E and
26
+ // zero-width joiners) and the line/paragraph separators; ordinary non-ASCII
27
+ // letters stay allowed.
28
+ const NON_PRINTABLE_RE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
29
+ function isValidRefusal(r) {
30
+ return (typeof r.error === 'string' &&
31
+ REFUSAL_SLUG_RE.test(r.error) &&
32
+ typeof r.message === 'string' &&
33
+ r.message.length > 0 &&
34
+ r.message.length <= MAX_REFUSAL_MESSAGE_CHARS &&
35
+ !NON_PRINTABLE_RE.test(r.message));
36
+ }
19
37
  export function parseOwnerEmails(env = process.env) {
20
38
  return (env.MCP_OWNER_EMAILS ?? '')
21
39
  .split(',')
@@ -366,12 +384,27 @@ export class HttpTransportHost {
366
384
  validationEnvelope: this.opts.validationEnvelope,
367
385
  };
368
386
  if (this.opts.resolveServer) {
387
+ let resolved;
369
388
  try {
370
- target = await this.opts.resolveServer({ sub });
389
+ resolved = await this.opts.resolveServer({ sub });
371
390
  }
372
391
  catch {
373
- target = null;
392
+ resolved = null;
393
+ }
394
+ if (resolved && 'refused' in resolved) {
395
+ const { refused } = resolved;
396
+ if (isValidRefusal(refused)) {
397
+ res.writeHead(403, { 'Content-Type': 'application/json' });
398
+ res.end(JSON.stringify({ error: refused.error, message: refused.message }));
399
+ this.log(`403 ${refused.error} path=/mcp`);
400
+ return;
401
+ }
402
+ // An invalid shape falls through to today's exact answer rather than
403
+ // forwarding something unvalidated; a refusal never reaches dispatch
404
+ // either way.
405
+ resolved = null;
374
406
  }
407
+ target = resolved;
375
408
  if (!target) {
376
409
  res.writeHead(403, { 'Content-Type': 'application/json' });
377
410
  res.end(JSON.stringify({ error: 'tenant_not_found', message: 'no tenant is provisioned for this subject' }));
@@ -107,7 +107,11 @@ export interface AuthServerDeps {
107
107
  hd?: string;
108
108
  }) => {
109
109
  sub: string;
110
- } | null;
110
+ } | null | {
111
+ denied: {
112
+ description: string;
113
+ };
114
+ };
111
115
  /** alias_add completion: persist the new alias's config row and tokens under
112
116
  * its tenant. /callback awaits it only after a successful Google exchange.
113
117
  * A returned refusal answers 403 with its slug; a throw or rejection answers
package/dist/oauth-as.js CHANGED
@@ -22,6 +22,11 @@ export const DCR_MAX_REDIRECT_URIS = 10;
22
22
  const DCR_MAX_URI_LEN = 2048;
23
23
  const OIDC_SUB = /^[\x21-\x7e]{1,255}$/;
24
24
  const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
25
+ // RFC 6749's error_description charset: %x20-21 / %x23-5B / %x5D-7E (printable
26
+ // ASCII minus the backslash and double-quote that would need escaping in the
27
+ // query string). A resolveSubject denial outside this set is dropped rather
28
+ // than forwarded unvalidated (H3 PR1).
29
+ const ERROR_DESCRIPTION_RE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,200}$/;
25
30
  /** Only trust the id_token identity when Google marks the email verified (#8).
26
31
  * The token comes straight from Google's token endpoint, so its claims are
27
32
  * read unsigned. A malformed sub or hd is dropped, never passed on. */
@@ -570,12 +575,15 @@ export function buildAuthServer(config, deps = {}) {
570
575
  // signed into the authz code and flows into every token minted from it.
571
576
  const email = (exchanged.email ?? '').toLowerCase();
572
577
  const iss = encodeURIComponent(base);
573
- const subject = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
574
- if (!subject) {
578
+ const resolved = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
579
+ if (!resolved || 'denied' in resolved) {
575
580
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
576
581
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
577
- return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
582
+ const desc = resolved && 'denied' in resolved ? resolved.denied.description : undefined;
583
+ const d = desc !== undefined && ERROR_DESCRIPTION_RE.test(desc) ? `&error_description=${encodeURIComponent(desc)}` : '';
584
+ return redirect(res, `${st.redirect_uri}${sep}error=access_denied${d}${s}&iss=${iss}`);
578
585
  }
586
+ const subject = resolved;
579
587
  const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, flowSecret, nowSec());
580
588
  log('callback ok flow=owner_gate');
581
589
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
@@ -3,6 +3,7 @@
3
3
  // or share outside the operator's list), a safety feature first and an
4
4
  // enterprise checkbox second. FREE-core forever (ee-definition-v0 handoff).
5
5
  // Off by default: an unset/empty GOOGLE_OUTBOUND_ALLOWLIST gates nothing.
6
+ import { hints } from './hint-provider.js';
6
7
  /** Parse GOOGLE_OUTBOUND_ALLOWLIST: comma-separated addresses and @domain
7
8
  * suffixes ("a@b.com, @company.com"). null = feature off (nothing gated). */
8
9
  export function resolveOutboundAllowlist(env = process.env) {
@@ -34,6 +35,7 @@ export function outboundViolations(emails, env = process.env) {
34
35
  /** The standard error envelope for a blocked outbound target. */
35
36
  export function outboundDeniedEnvelope(kind, blocked, account, env = process.env) {
36
37
  const list = resolveOutboundAllowlist(env);
38
+ const rendered = hints()?.render({ kind: 'outbound_denied', what: kind, blocked, entries: list?.entries ?? [] });
37
39
  return {
38
40
  content: [
39
41
  {
@@ -41,8 +43,9 @@ export function outboundDeniedEnvelope(kind, blocked, account, env = process.env
41
43
  text: JSON.stringify({
42
44
  error: 'recipient_not_allowed',
43
45
  message: `${kind} blocked by the outbound allowlist: ${blocked.join(', ')}.`,
44
- hint: `GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
45
- 'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.',
46
+ hint: rendered?.hint ??
47
+ (`GOOGLE_OUTBOUND_ALLOWLIST is active (${(list?.entries ?? []).join(', ')}). ` +
48
+ 'The operator must add the address (or its @domain) to the list, or unset the variable, to allow this target.'),
46
49
  retriable: false,
47
50
  account,
48
51
  }),
@@ -3,11 +3,15 @@
3
3
  // user cannot run a CLI, so the hint becomes a clickable, server-signed link
4
4
  // into the AS's clientless alias_reauth flow (AuthServer.reauthLink). The HTTP
5
5
  // bootstrap installs the linker when the AS starts; stdio never does.
6
+ import { hints } from './hint-provider.js';
6
7
  let httpReauthLink = null;
7
8
  export function setHttpReauthLink(linker) {
8
9
  httpReauthLink = linker;
9
10
  }
10
11
  export function reauthHint(account) {
12
+ const rendered = hints()?.render({ kind: 'reauth', account });
13
+ if (rendered)
14
+ return rendered.hint;
11
15
  if (httpReauthLink) {
12
16
  return `Re-authenticate "${account}" in your browser, then retry: ${httpReauthLink(account)}`;
13
17
  }
package/dist/registry.js CHANGED
@@ -6,6 +6,7 @@ import { fanoutAccountField, invalidAccountsResult, noAccountsResult, parseAccou
6
6
  import { MAX_RESPONSE_CHARS } from './executor.js';
7
7
  import { MAX_SUGGESTED_KEYS, suggestKeys } from './arg-strict.js';
8
8
  import { classifyScope } from './scope-observability.js';
9
+ import { hints } from './hint-provider.js';
9
10
  /** Nothing granted, no profile: asks the BUNDLE CATALOG whether a scope is
10
11
  * reachable at all, independently of any account. */
11
12
  const EMPTY_SCOPES = new Set();
@@ -206,6 +207,8 @@ export class ToolRegistry {
206
207
  refreshAccountSetIfStale();
207
208
  const def = this.accounts().defaultAccount;
208
209
  if (!def) {
210
+ const aliases = this.accounts().aliases;
211
+ const rendered = hints()?.render({ kind: 'no_default_account', aliases });
209
212
  return {
210
213
  content: [
211
214
  {
@@ -213,7 +216,7 @@ export class ToolRegistry {
213
216
  text: JSON.stringify({
214
217
  error: 'E_NO_DEFAULT_ACCOUNT',
215
218
  message: 'No "account" given and no default account is configured.',
216
- hint: `Pass account explicitly (valid: ${this.accounts().aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
219
+ hint: rendered?.hint ?? `Pass account explicitly (valid: ${aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
217
220
  retriable: false,
218
221
  }),
219
222
  },
@@ -2,6 +2,7 @@ import { ADMIN_SCOPES, BUNDLE_CATALOG } from './scope-catalog.js';
2
2
  import { BASE_SCOPES, resolveScopesForAccount } from './auth.js';
3
3
  import { readToken } from './token-store.js';
4
4
  import { reauthHint } from './reauth-hint.js';
5
+ import { hints } from './hint-provider.js';
5
6
  // Reverse index scope -> bundles offering it, narrowest bundle first (fewest
6
7
  // scopes) so the remediation names the smallest grant. BASE_SCOPES map to the
7
8
  // pseudo-bundle "base" (outside a profile only via includesBase:false).
@@ -72,40 +73,54 @@ export function classifyMethodScopes(alternatives, granted, profile) {
72
73
  */
73
74
  export function scopeHint(scope, c, alias) {
74
75
  switch (c.state) {
75
- case 'requestable_not_granted':
76
+ case 'requestable_not_granted': {
77
+ const rendered = hints()?.render({ kind: 'scope_not_granted', account: alias, scope });
76
78
  return {
77
- hint: `Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
78
- reauthHint(alias),
79
- retriable: true,
79
+ hint: rendered?.hint ??
80
+ (`Scope ${scope} is in ${alias}'s profile but not granted (you unchecked it at consent). ` +
81
+ reauthHint(alias)),
82
+ // The ONE kind whose retriable the host may flip (Decision 3): the
83
+ // owner keeps true, a multi-tenant host may report false because the
84
+ // fix needs the user in a browser.
85
+ retriable: rendered?.retriable ?? true,
80
86
  };
87
+ }
81
88
  case 'not_requestable':
82
89
  if (c.reason === 'add_bundle') {
83
90
  // "base" is a pseudo-bundle (BASE_SCOPES excluded by includesBase:
84
91
  // false), NOT a catalog key — telling users to add it to bundles[]
85
92
  // would fail boot with E_UNKNOWN_BUNDLE.
86
93
  if (c.bundle === 'base') {
94
+ const rendered = hints()?.render({ kind: 'scope_base_excluded', account: alias, scope });
87
95
  return {
88
- hint: `Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
89
- `Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`,
96
+ hint: rendered?.hint ??
97
+ (`Scope ${scope} is a base scope excluded by "includesBase": false in ${alias}'s profile. ` +
98
+ `Set includesBase back to true (or remove it) in config.json, then npx mcp-google-multi auth --account ${alias}`),
90
99
  retriable: false,
91
100
  };
92
101
  }
102
+ const rendered = hints()?.render({ kind: 'scope_needs_bundle', account: alias, scope, bundle: c.bundle });
93
103
  return {
94
- hint: `Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
95
- `Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`,
104
+ hint: rendered?.hint ??
105
+ (`Scope ${scope} needs the "${c.bundle}" bundle, which is not in ${alias}'s profile. ` +
106
+ `Add it: set ${alias}'s profile bundles to include "${c.bundle}" in config.json, then npx mcp-google-multi auth --account ${alias}`),
96
107
  retriable: false,
97
108
  };
98
109
  }
99
110
  if (c.reason === 'account_type') {
111
+ const rendered = hints()?.render({ kind: 'scope_account_type', account: alias, scope });
100
112
  return {
101
- hint: `Scope ${scope} requires a Google Workspace account; ${alias} is a personal account and will 403. Use a Workspace alias. Do not retry.`,
113
+ hint: rendered?.hint ?? `Scope ${scope} requires a Google Workspace account; ${alias} is a personal account and will 403. Use a Workspace alias. Do not retry.`,
114
+ retriable: false,
115
+ };
116
+ }
117
+ {
118
+ const rendered = hints()?.render({ kind: 'scope_unknown_scope', scope });
119
+ return {
120
+ hint: rendered?.hint ?? `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
102
121
  retriable: false,
103
122
  };
104
123
  }
105
- return {
106
- hint: `Scope ${scope} is not offered by this server (no bundle grants it). Not requestable. Do not retry.`,
107
- retriable: false,
108
- };
109
124
  default:
110
125
  return { hint: '', retriable: true };
111
126
  }
package/dist/services.js CHANGED
@@ -18,6 +18,7 @@ import { getToolsets, toolsetEnabled } from './toolsets.js';
18
18
  import { GENERATED_SERVICES } from './tools/generated/index.js';
19
19
  import { suggestKeys } from './arg-strict.js';
20
20
  import { sliceClean } from './trim.js';
21
+ import { hints } from './hint-provider.js';
21
22
  export const SERVICES = [
22
23
  { name: 'gmail', register: registerGmailTools },
23
24
  { name: 'drive', register: registerDriveTools },
@@ -73,16 +74,30 @@ export function unknownToolMessage(registry, rawName) {
73
74
  const service = serviceOf(name);
74
75
  const known = SERVICES.some((s) => s.name === service) || GENERATED_SERVICES.some((s) => s.name === service);
75
76
  if (known && !registry.services().includes(service)) {
77
+ const aliases = registry.accountSet().aliases;
76
78
  if (!toolsetEnabled(getToolsets(), service)) {
77
- return `Tool ${name} not found: the "${service}" service is turned off in this deployment (GOOGLE_TOOLSETS).`;
79
+ const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: true, awaitingRestart: false });
80
+ return rendered?.hint ?? `Tool ${name} not found: the "${service}" service is turned off in this deployment (GOOGLE_TOOLSETS).`;
78
81
  }
79
82
  if (servicesAwaitingRestart(registry).includes(service)) {
80
- return `Tool ${name} not found: the "${service}" service was enabled after this server started, and tools register at startup. Restart the server to use them. Until then, google_api_call can reach the same API.`;
83
+ const rendered = hints()?.render({ kind: 'service_not_enabled', tool: name, service, aliases, admin: false, toolsetOff: false, awaitingRestart: true });
84
+ return rendered?.hint ?? `Tool ${name} not found: the "${service}" service was enabled after this server started, and tools register at startup. Restart the server to use them. Until then, google_api_call can reach the same API.`;
81
85
  }
82
- const hint = service === 'admin'
86
+ const isAdmin = service === 'admin';
87
+ const hint = isAdmin
83
88
  ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS), then re-auth'
84
89
  : (GENERATED_GATES[service]?.hint ?? `add "${service}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES), then re-auth`);
85
- return `Tool ${name} not found: the "${service}" service is not enabled in this deployment. To enable it, ${hint}. Until then, google_api_call can reach the same API.`;
90
+ const rendered = hints()?.render({
91
+ kind: 'service_not_enabled',
92
+ tool: name,
93
+ service,
94
+ bundle: isAdmin ? undefined : service,
95
+ aliases,
96
+ admin: isAdmin,
97
+ toolsetOff: false,
98
+ awaitingRestart: false,
99
+ });
100
+ return rendered?.hint ?? `Tool ${name} not found: the "${service}" service is not enabled in this deployment. To enable it, ${hint}. Until then, google_api_call can reach the same API.`;
86
101
  }
87
102
  const near = suggestKeys(rawName, registry.toolNames(), 3);
88
103
  if (near.length > 0)
@@ -6,6 +6,9 @@ import type { Account } from '../accounts.js';
6
6
  export interface ServiceHints {
7
7
  scope?: string;
8
8
  resource?: string;
9
+ /** The scope bundle the scope hint is about, when known; carried through to
10
+ * a host-supplied provider so it can name the bundle in-band. */
11
+ bundle?: string;
9
12
  }
10
13
  export interface ErrorEnvelope {
11
14
  error: string;
@@ -1,5 +1,6 @@
1
1
  import { reauthHint } from '../reauth-hint.js';
2
2
  import { sliceClean, sliceEncoded } from '../trim.js';
3
+ import { hints as hintProvider } from '../hint-provider.js';
3
4
  const MAX_ERROR_MESSAGE_CHARS = 1000;
4
5
  const MAX_ERROR_ENVELOPE_CHARS = 4000;
5
6
  const MAX_BODY_PARSE_CHARS = 64_000;
@@ -175,10 +176,11 @@ function rateLimited(error, message, account, reason) {
175
176
  };
176
177
  }
177
178
  if (reason === 'dailyLimitExceeded') {
179
+ const rendered = hintProvider()?.render({ kind: 'daily_quota' });
178
180
  return {
179
181
  error: 'rate_limited',
180
182
  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.',
183
+ hint: rendered?.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
184
  retriable: true,
183
185
  account,
184
186
  };
@@ -222,21 +224,24 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
222
224
  .filter(Boolean)
223
225
  .join(' ');
224
226
  if ((status === 400 || status === 401) && /invalid_grant/i.test(grantHaystack)) {
227
+ const rendered = hintProvider()?.render({ kind: 'dead_refresh_token', account });
225
228
  return {
226
229
  error: 'reauth_required',
227
230
  message,
228
- hint: `Refresh token for "${account}" is dead (often the 7-day Testing-mode trap). ` +
229
- 'Set Publishing status to In production at https://console.cloud.google.com/auth/audience, ' +
230
- `then ${reauthHint(account)}`,
231
+ hint: rendered?.hint ??
232
+ (`Refresh token for "${account}" is dead (often the 7-day Testing-mode trap). ` +
233
+ 'Set Publishing status to In production at https://console.cloud.google.com/auth/audience, ' +
234
+ `then ${reauthHint(account)}`),
231
235
  retriable: false,
232
236
  account,
233
237
  };
234
238
  }
235
239
  if (status === 401) {
240
+ const rendered = hintProvider()?.render({ kind: 'reauth', account });
236
241
  return {
237
242
  error: 'auth_required',
238
243
  message: `Authentication failed for account "${account}".`,
239
- hint: reauthHint(account),
244
+ hint: rendered?.hint ?? reauthHint(account),
240
245
  retriable: false,
241
246
  account,
242
247
  };
@@ -252,7 +257,14 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
252
257
  reason === 'SERVICE_DISABLED' ||
253
258
  /has not been used in project|accessNotConfigured|SERVICE_DISABLED|it is disabled/i.test(message);
254
259
  if (notEnabled) {
255
- return { error: 'api_not_enabled', message, hint: enableHint(info, message), retriable: false, account };
260
+ const rendered = hintProvider()?.render({
261
+ kind: 'api_not_enabled',
262
+ api: disabledApiId(message) ?? undefined,
263
+ service: info?.metadata?.service,
264
+ project: info?.metadata?.consumer?.replace(/^projects\//, ''),
265
+ googleMessage: message,
266
+ });
267
+ return { error: 'api_not_enabled', message, hint: rendered?.hint ?? enableHint(info, message), retriable: false, account };
256
268
  }
257
269
  // The scope hint fires ONLY on the machine-readable token. A coarse AIP
258
270
  // status of PERMISSION_DENIED is equally true of a resource denial, and
@@ -262,10 +274,13 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
262
274
  /\bACCESS_TOKEN_SCOPE_INSUFFICIENT\b/.test(message);
263
275
  if (scopeIssue) {
264
276
  const enriched = scopeContext?.() ?? null;
277
+ const fallback = enriched
278
+ ? null
279
+ : hintProvider()?.render({ kind: 'scope_unknown', account, serviceText: hints.scope, bundle: hints.bundle });
265
280
  return {
266
281
  error: 'insufficient_scope',
267
282
  message,
268
- hint: enriched?.hint ?? hints.scope ?? `Re-auth "${account}" with the scope this operation needs. ${reauthHint(account)}`,
283
+ hint: enriched?.hint ?? fallback?.hint ?? hints.scope ?? `Re-auth "${account}" with the scope this operation needs. ${reauthHint(account)}`,
269
284
  retriable: enriched?.retriable ?? false,
270
285
  account,
271
286
  };
@@ -274,7 +289,8 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
274
289
  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
290
  }
276
291
  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 };
292
+ const rendered = hintProvider()?.render({ kind: 'project_suspended', account });
293
+ return { error: 'forbidden', message, hint: rendered?.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
294
  }
279
295
  if (reason === 'domainPolicy' || reason === 'ORG_RESTRICTION_VIOLATION') {
280
296
  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 };
@@ -289,10 +305,11 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
289
305
  };
290
306
  }
291
307
  if (status === 400 && /invalid[_ ]scope/i.test(message)) {
308
+ const rendered = hintProvider()?.render({ kind: 'invalid_scope', account });
292
309
  return {
293
310
  error: 'invalid_scope',
294
311
  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.',
312
+ hint: rendered?.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
313
  retriable: false,
297
314
  account,
298
315
  };
@@ -335,13 +352,15 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
335
352
  // The server's own preconditions, tagged at the throw site in client.ts.
336
353
  // Untagged they reached the floor and were reported as Google failures.
337
354
  if (error?.code === 'E_NO_TOKEN') {
338
- return { error: 'auth_required', message, hint: reauthHint(account), retriable: false, account };
355
+ const rendered = hintProvider()?.render({ kind: 'reauth', account });
356
+ return { error: 'auth_required', message, hint: rendered?.hint ?? reauthHint(account), retriable: false, account };
339
357
  }
340
358
  if (error?.code === 'E_NO_OAUTH_CLIENT') {
359
+ const rendered = hintProvider()?.render({ kind: 'no_oauth_client' });
341
360
  return {
342
361
  error: 'invalid_client',
343
362
  message,
344
- hint: 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (see docs/google-cloud-setup.md), then restart the server.',
363
+ hint: rendered?.hint ?? 'Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (see docs/google-cloud-setup.md), then restart the server.',
345
364
  retriable: false,
346
365
  account,
347
366
  };
@@ -357,23 +376,30 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
357
376
  }
358
377
  const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
359
378
  if (fsCode) {
360
- const p = typeof error?.path === 'string' ? ` "${error.path}"` : '';
379
+ const p = typeof error?.path === 'string' ? error.path : undefined;
380
+ const rendered = hintProvider()?.render({ kind: 'local_path', code: fsCode, path: p });
361
381
  return {
362
382
  error: 'invalid_params',
363
- message: `Cannot access local path${p}: ${fsCode}`,
364
- hint: 'The path must exist on the machine running this server and be accessible to it. ' +
365
- 'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.',
383
+ message: rendered?.message ?? `Cannot access local path${p ? ` "${p}"` : ''}: ${fsCode}`,
384
+ hint: rendered?.hint ??
385
+ ('The path must exist on the machine running this server and be accessible to it. ' +
386
+ 'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.'),
366
387
  retriable: false,
367
388
  account,
368
389
  };
369
390
  }
370
391
  const netCode = netCodeOf(error);
371
392
  if (netCode) {
393
+ const rendered = hintProvider()?.render({ kind: 'network_error', code: netCode });
372
394
  return {
373
395
  error: 'network_error',
374
396
  message: message.includes(netCode) ? message : message.endsWith('reason: ') ? `${message}${netCode}` : `${message} (${netCode})`,
375
- hint: `Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
376
- '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.',
397
+ hint: rendered?.hint ??
398
+ (`Network failure (${netCode}) before reaching Google - not an auth or API problem. Usually transient: retry. ` +
399
+ '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 (C5 is the only kind whose retriable the host
401
+ // may flip): a multi-tenant host keeps core's retriable codes for
402
+ // network_error.
377
403
  retriable: RETRIABLE_NET_CODES.has(netCode),
378
404
  account,
379
405
  };
@@ -392,13 +418,16 @@ export function mapGoogleError(error, account, serviceHints, scopeContext) {
392
418
  account,
393
419
  };
394
420
  }
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
- };
421
+ {
422
+ const rendered = hintProvider()?.render({ kind: 'internal', account });
423
+ return {
424
+ error: 'internal',
425
+ message: rendered?.message ?? message,
426
+ hint: rendered?.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.',
427
+ retriable: false,
428
+ account,
429
+ };
430
+ }
402
431
  }
403
432
  /** Bound the SERIALIZED envelope, not just the message: an authored hint can
404
433
  * be long too, and the error path had no cap at all before this. */
@@ -4,6 +4,7 @@ import { getAccountSet, refreshAccountSetIfStale } from '../accounts.js';
4
4
  import { buildScopesReport } from '../scope-observability.js';
5
5
  import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
6
6
  import { hasToken, readToken } from '../token-store.js';
7
+ import { hints } from '../hint-provider.js';
7
8
  /** Health deps over one context's token store (and, optionally, its account
8
9
  * view); the module-level functions are the single owner's store. */
9
10
  export function accountHealthDepsFor(store, accounts) {
@@ -37,13 +38,17 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
37
38
  const noScopes = buildScopesReport(new Set(), profileSet, registered);
38
39
  if (!deps.hasToken(alias)) {
39
40
  const legacy = deps.fileExists(config.tokenPath);
41
+ const rendered = legacy
42
+ ? hints()?.render({ kind: 'legacy_plaintext_token', account: alias })
43
+ : hints()?.render({ kind: 'reauth', account: alias });
40
44
  return {
41
45
  ...base,
42
46
  token: {
43
47
  status: 'missing',
44
- hint: legacy
45
- ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
46
- : reauthHint(alias),
48
+ hint: rendered?.hint ??
49
+ (legacy
50
+ ? 'Plaintext token.json found — run: npx mcp-google-multi migrate-tokens'
51
+ : reauthHint(alias)),
47
52
  },
48
53
  scopes: noScopes,
49
54
  };
@@ -53,9 +58,10 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
53
58
  token = deps.readToken(alias);
54
59
  }
55
60
  catch {
61
+ const rendered = hints()?.render({ kind: 'token_unreadable', account: alias });
56
62
  return {
57
63
  ...base,
58
- token: { status: 'decrypt_error', hint: 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
64
+ token: { status: 'decrypt_error', hint: rendered?.hint ?? 'Wrong MASTER_KEY or corrupt token file — re-auth or restore the key.' },
59
65
  scopes: noScopes,
60
66
  };
61
67
  }
@@ -77,9 +83,9 @@ export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
77
83
  status,
78
84
  expiryDate: expiry !== undefined ? new Date(expiry).toISOString() : undefined,
79
85
  hint: status === 'needs_reauth'
80
- ? reauthHint(alias)
86
+ ? (hints()?.render({ kind: 'reauth', account: alias })?.hint ?? reauthHint(alias))
81
87
  : missing.length > 0
82
- ? 'Re-auth to grant the missing scopes'
88
+ ? (hints()?.render({ kind: 'scopes_missing', account: alias })?.hint ?? 'Re-auth to grant the missing scopes')
83
89
  : undefined,
84
90
  },
85
91
  scopes: report,
@@ -1,3 +1,11 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { Account } from '../accounts.js';
2
3
  import { type CuratedToolDeps } from '../client.js';
3
4
  export declare function registerAdminTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
5
+ export declare function handleAdminError(error: any, account: Account): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
@@ -227,7 +227,7 @@ export function registerAdminTools(server, deps = {}) {
227
227
  }
228
228
  });
229
229
  }
230
- function handleAdminError(error, account) {
230
+ export function handleAdminError(error, account) {
231
231
  const admin = 'Admin tools require Workspace super-admin privileges AND the account must be listed as an admin account (then re-authenticated). Personal Gmail accounts cannot use these endpoints.';
232
- return handleGoogleApiError(error, account, { scope: admin, resource: admin });
232
+ return handleGoogleApiError(error, account, { scope: admin, resource: admin, bundle: 'admin' });
233
233
  }
@@ -1,4 +1,5 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { Account } from '../accounts.js';
2
3
  import { type CuratedToolDeps } from '../client.js';
3
4
  /** Accepts "213025502" or "properties/213025502"; rejects the identifiers
4
5
  * people paste by mistake (G-… measurement IDs, UA-… properties) with a
@@ -17,3 +18,10 @@ export declare function shapeAccountSummaries(data: any): Record<string, unknown
17
18
  * the disambiguation the model needs without bloating a ~300-entry list. */
18
19
  export declare function shapeMetadata(data: any): Record<string, unknown>;
19
20
  export declare function registerAnalyticsTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
21
+ export declare function handleAnalyticsError(error: any, account: Account): {
22
+ content: {
23
+ type: "text";
24
+ text: string;
25
+ }[];
26
+ isError: true;
27
+ };
@@ -278,11 +278,12 @@ function invalidProperty(hint, account) {
278
278
  isError: true,
279
279
  };
280
280
  }
281
- function handleAnalyticsError(error, account) {
281
+ export function handleAnalyticsError(error, account) {
282
282
  return handleGoogleApiError(error, account, {
283
283
  scope: 'Needs the "analytics" bundle on this account: add it to the scope profile, then re-auth.',
284
284
  // GA4 answers PERMISSION_DENIED for a property ACL too, and the old
285
285
  // combined hint sent those callers to re-auth a scope they already had.
286
286
  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
+ bundle: 'analytics',
287
288
  });
288
289
  }
@@ -1,3 +1,11 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { Account } from '../accounts.js';
2
3
  import { type CuratedToolDeps } from '../client.js';
3
4
  export declare function registerChatTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
5
+ export declare function handleChatError(error: any, account: Account): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
@@ -121,9 +121,10 @@ export function registerChatTools(server, deps = {}) {
121
121
  }
122
122
  });
123
123
  }
124
- function handleChatError(error, account) {
124
+ export function handleChatError(error, account) {
125
125
  return handleGoogleApiError(error, account, {
126
126
  scope: 'Chat tools require the optional "chat" scope bundle: add it to this account\'s scope profile, then re-auth.',
127
127
  resource: `Google Chat denied this space or message to "${account}". The scope is not the problem: check that the account is a member of the space, and that Chat is turned on for the Workspace.`,
128
+ bundle: 'chat',
128
129
  });
129
130
  }
@@ -1,3 +1,11 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { Account } from '../accounts.js';
2
3
  import { type CuratedToolDeps } from '../client.js';
3
4
  export declare function registerFormsTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
5
+ export declare function handleFormsError(error: any, account: Account): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
@@ -190,6 +190,9 @@ export function registerFormsTools(server, deps = {}) {
190
190
  }
191
191
  });
192
192
  }
193
- function handleFormsError(error, account) {
194
- return handleGoogleApiError(error, account, "Forms tools require the optional \"forms\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=forms (or include \"forms\" in the list) and re-auth.");
193
+ export function handleFormsError(error, account) {
194
+ return handleGoogleApiError(error, account, {
195
+ scope: "Forms tools require the optional \"forms\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=forms (or include \"forms\" in the list) and re-auth.",
196
+ bundle: 'forms',
197
+ });
195
198
  }
@@ -7,6 +7,7 @@ import { getToolsets, toolsetEnabled } from '../toolsets.js';
7
7
  import { editDistance, SUGGEST_MAX_INPUT } from '../scope-catalog.js';
8
8
  import { executeApiMethod, jsonResult } from '../executor.js';
9
9
  import { SUPPORTED_APIS, resolveApiAliases, cudFromMethod, loadMethodIndex, searchMethods, MAX_SEARCH_QUERY, } from '../discovery-client.js';
10
+ import { hints } from '../hint-provider.js';
10
11
  // Policy/toolset namespace for each API alias must match the NAMED tools' service
11
12
  // names, or user deny globs and GOOGLE_TOOLSETS silently miss escape-hatch calls.
12
13
  const SERVICE_FOR_ALIAS = {
@@ -73,12 +74,15 @@ export function registerEscapeTools(registry, policy, deps = {}) {
73
74
  };
74
75
  const enabledApis = Object.keys(SUPPORTED_APIS).filter(apiEnabled);
75
76
  const apiList = enabledApis.join(', ');
76
- const toolsetDisabled = (alias) => jsonResult({
77
- error: 'toolset_disabled',
78
- message: `API "${alias}" maps to service "${serviceForAlias(alias)}", which is excluded by GOOGLE_TOOLSETS.`,
79
- hint: 'Add the service to GOOGLE_TOOLSETS (or unset it) to use this API.',
80
- retriable: false,
81
- }, true);
77
+ const toolsetDisabled = (alias) => {
78
+ const rendered = hints()?.render({ kind: 'toolset_disabled', api: alias, service: serviceForAlias(alias) });
79
+ return jsonResult({
80
+ error: 'toolset_disabled',
81
+ message: rendered?.message ?? `API "${alias}" maps to service "${serviceForAlias(alias)}", which is excluded by GOOGLE_TOOLSETS.`,
82
+ hint: rendered?.hint ?? 'Add the service to GOOGLE_TOOLSETS (or unset it) to use this API.',
83
+ retriable: false,
84
+ }, true);
85
+ };
82
86
  registry.registerMeta('google_api_search', {
83
87
  description: 'Search the Google API Discovery index for any Workspace REST method, including ones with no ' +
84
88
  'dedicated tool here. Returns method ids + parameters to invoke via google_api_call. ' +
@@ -1,3 +1,11 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { Account } from '../accounts.js';
2
3
  import { type CuratedToolDeps } from '../client.js';
3
4
  export declare function registerMeetTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
5
+ export declare function handleMeetError(error: any, account: Account): {
6
+ content: {
7
+ type: "text";
8
+ text: string;
9
+ }[];
10
+ isError: true;
11
+ };
@@ -132,7 +132,7 @@ export function registerMeetTools(server, deps = {}) {
132
132
  }
133
133
  });
134
134
  }
135
- function handleMeetError(error, account) {
135
+ export function handleMeetError(error, account) {
136
136
  return handleGoogleApiError(error, account, {
137
137
  scope: 'Meet needs the meetings.space.readonly scope and the Google Meet API enabled in Cloud Console. Confirm both for this account.',
138
138
  resource: `Meet denied this conference record to "${account}". Meet exposes records only to the organizer or a participant, so check which account hosted the meeting.`,
@@ -1,5 +1,13 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import type { Account } from '../accounts.js';
2
3
  import { type CuratedToolDeps } from '../client.js';
3
4
  export declare function registerSlidesTools(server: ToolRegistry, deps?: CuratedToolDeps): void;
4
5
  export declare function extractPageText(elements: Array<Record<string, any>> | undefined): string;
5
6
  export declare function summarizePresentation(p: Record<string, any>): Record<string, unknown>;
7
+ export declare function handleSlidesError(error: any, account: Account): {
8
+ content: {
9
+ type: "text";
10
+ text: string;
11
+ }[];
12
+ isError: true;
13
+ };
@@ -184,6 +184,9 @@ export function summarizePresentation(p) {
184
184
  hint: 'Pass full:true for the raw Presentation JSON, or slides_page_get for one slide.',
185
185
  };
186
186
  }
187
- function handleSlidesError(error, account) {
188
- return handleGoogleApiError(error, account, "Slides tools require the optional \"slides\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=slides (or include \"slides\" in the list) and re-auth.");
187
+ export function handleSlidesError(error, account) {
188
+ return handleGoogleApiError(error, account, {
189
+ scope: "Slides tools require the optional \"slides\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=slides (or include \"slides\" in the list) and re-auth.",
190
+ bundle: 'slides',
191
+ });
189
192
  }
@@ -1,3 +1,4 @@
1
+ import { hints } from './hint-provider.js';
1
2
  const PROFILES = ['read-only', 'safe-writes', 'full-writes'];
2
3
  /** The irreversible set (frozen, cc-write-control): real sends leave the
3
4
  * user's (or org's) identity unrecallably; permanent deletes bypass Trash.
@@ -155,10 +156,17 @@ export function writeDisabledResult(tool, policy, account) {
155
156
  // Recomputed rather than passed in: every call site has already run
156
157
  // isAllowed, and a hint that disagrees with the verdict is worse than none.
157
158
  const reason = denyReason(tool, policy) ?? { rule: 'profile', profile: policy.profile };
159
+ const rendered = hints()?.render({
160
+ kind: 'write_denied',
161
+ tool: { name: tool.name, service: tool.service, cud: tool.cud },
162
+ reason,
163
+ policy,
164
+ });
158
165
  const envelope = {
159
166
  error: 'write_disabled',
160
- message: `"${tool.name}" (${tool.cud}) is disabled by the current write-control policy (profile: ${policy.profile}${policy.readOnly ? ', GOOGLE_READ_ONLY=true' : ''}).`,
161
- hint: denyHint(tool, reason),
167
+ message: rendered?.message ??
168
+ `"${tool.name}" (${tool.cud}) is disabled by the current write-control policy (profile: ${policy.profile}${policy.readOnly ? ', GOOGLE_READ_ONLY=true' : ''}).`,
169
+ hint: rendered?.hint ?? denyHint(tool, reason),
162
170
  retriable: false,
163
171
  ...(account ? { account } : {}),
164
172
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.68",
3
+ "version": "6.0.0-alpha.69",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "./auth": "./dist/auth.js",
33
33
  "./usage-metrics": "./dist/usage-metrics.js",
34
34
  "./metrics-tap": "./dist/metrics-tap.js",
35
+ "./hint-provider": "./dist/hint-provider.js",
35
36
  "./package.json": "./package.json"
36
37
  },
37
38
  "files": [