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 +0 -0
- package/dist/discover.js +3 -1
- package/dist/doctor.js +41 -10
- package/dist/executor.js +3 -1
- package/dist/fanout.js +9 -1
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +11 -1
- package/dist/http-transport.js +35 -2
- package/dist/oauth-as.d.ts +5 -1
- package/dist/oauth-as.js +11 -3
- package/dist/outbound-allowlist.js +5 -2
- package/dist/reauth-hint.js +4 -0
- package/dist/registry.js +4 -1
- package/dist/scope-observability.js +28 -13
- package/dist/services.js +19 -4
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +53 -24
- package/dist/tools/accounts-tool.js +12 -6
- package/dist/tools/admin.d.ts +8 -0
- package/dist/tools/admin.js +2 -2
- package/dist/tools/analytics.d.ts +8 -0
- package/dist/tools/analytics.js +2 -1
- package/dist/tools/chat.d.ts +8 -0
- package/dist/tools/chat.js +2 -1
- package/dist/tools/forms.d.ts +8 -0
- package/dist/tools/forms.js +5 -2
- package/dist/tools/google-api.js +10 -6
- package/dist/tools/meet.d.ts +8 -0
- package/dist/tools/meet.js +1 -1
- package/dist/tools/slides.d.ts +8 -0
- package/dist/tools/slides.js +5 -2
- package/dist/write-control.js +10 -2
- package/package.json +2 -1
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
+
}
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/http-transport.js
CHANGED
|
@@ -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
|
-
|
|
389
|
+
resolved = await this.opts.resolveServer({ sub });
|
|
371
390
|
}
|
|
372
391
|
catch {
|
|
373
|
-
|
|
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' }));
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -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
|
|
574
|
-
if (!
|
|
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
|
-
|
|
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:
|
|
45
|
-
|
|
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
|
}),
|
package/dist/reauth-hint.js
CHANGED
|
@@ -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: ${
|
|
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:
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
89
|
-
`
|
|
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:
|
|
95
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/dist/tools/_errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/_errors.js
CHANGED
|
@@ -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:
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' ?
|
|
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:
|
|
365
|
-
'
|
|
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:
|
|
376
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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:
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
package/dist/tools/admin.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/tools/admin.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/tools/analytics.js
CHANGED
|
@@ -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
|
}
|
package/dist/tools/chat.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/tools/chat.js
CHANGED
|
@@ -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
|
}
|
package/dist/tools/forms.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/tools/forms.js
CHANGED
|
@@ -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,
|
|
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
|
}
|
package/dist/tools/google-api.js
CHANGED
|
@@ -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) =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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. ' +
|
package/dist/tools/meet.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/tools/meet.js
CHANGED
|
@@ -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.`,
|
package/dist/tools/slides.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/tools/slides.js
CHANGED
|
@@ -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,
|
|
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
|
}
|
package/dist/write-control.js
CHANGED
|
@@ -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:
|
|
161
|
-
|
|
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.
|
|
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": [
|