mcp-google-multi 6.0.0-alpha.67 → 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/mcp-token.d.ts +20 -0
- package/dist/mcp-token.js +53 -3
- package/dist/oauth-as.d.ts +9 -1
- package/dist/oauth-as.js +14 -6
- 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/token-store.js +0 -0
- 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/usage-metrics.d.ts +21 -4
- package/dist/usage-metrics.js +43 -31
- package/dist/write-control.js +10 -2
- package/package.json +4 -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/mcp-token.d.ts
CHANGED
|
@@ -123,6 +123,14 @@ export interface RefreshStoreOptions {
|
|
|
123
123
|
maxAgeSec?: number;
|
|
124
124
|
/** Idle lifetime in seconds, counted from the last issue or rotation. Unset: unlimited. */
|
|
125
125
|
idleSec?: number;
|
|
126
|
+
/** Max live refresh-token families per subject (both formats). Unset:
|
|
127
|
+
* unlimited (default; the single-owner server passes none). When issuing a
|
|
128
|
+
* new family would put a subject over this, that subject's own
|
|
129
|
+
* least-recently-used family is dropped (by its last issue or rotation
|
|
130
|
+
* time) until it fits; a dropped family behaves exactly like a revoked
|
|
131
|
+
* one. Only the subject signing in ever triggers it, and only its own
|
|
132
|
+
* families are ever touched. */
|
|
133
|
+
maxFamiliesPerSub?: number;
|
|
126
134
|
/** Server-side log for a revoked, dropped or expired family. Never given a token or a subject. */
|
|
127
135
|
log?: (line: string) => void;
|
|
128
136
|
}
|
|
@@ -141,6 +149,7 @@ export declare class RefreshStore {
|
|
|
141
149
|
private readonly log;
|
|
142
150
|
private readonly maxAgeMs;
|
|
143
151
|
private readonly idleMs;
|
|
152
|
+
private readonly maxFamiliesPerSub;
|
|
144
153
|
private macKey;
|
|
145
154
|
private legacyEmpty;
|
|
146
155
|
constructor(path: string, masterKey: string, options?: RefreshStoreOptions);
|
|
@@ -156,6 +165,17 @@ export declare class RefreshStore {
|
|
|
156
165
|
private load;
|
|
157
166
|
private save;
|
|
158
167
|
private emit;
|
|
168
|
+
/** Evict `sub`'s own least-recently-used sessions (both formats) so at
|
|
169
|
+
* most `limit` of its live sessions remain; dropping one here is the same
|
|
170
|
+
* as deleting it from `data.families` / `data.active` on a revoke, so it
|
|
171
|
+
* can never be presented again (reuse detection for every OTHER family is
|
|
172
|
+
* untouched). Only `sub`'s own entries are ever inspected.
|
|
173
|
+
*
|
|
174
|
+
* `keepKey` is the family just issued: it is excluded from the ranking so
|
|
175
|
+
* a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
|
|
176
|
+
* racing ahead of it under the lock, can never make the brand-new family
|
|
177
|
+
* look like the least recently used one and drop itself. */
|
|
178
|
+
private enforceFamilyCap;
|
|
159
179
|
private newFamilyId;
|
|
160
180
|
issue(nowMs: number, sub: string): string;
|
|
161
181
|
/** Rotate a presented refresh token; null if it is unknown, malformed, or
|
package/dist/mcp-token.js
CHANGED
|
@@ -363,12 +363,16 @@ export function assertRefreshStoreReadable(file, masterKey) {
|
|
|
363
363
|
}
|
|
364
364
|
checkFormat(file, d);
|
|
365
365
|
}
|
|
366
|
-
function
|
|
366
|
+
function positiveInt(name, v) {
|
|
367
367
|
if (v === undefined)
|
|
368
368
|
return undefined;
|
|
369
369
|
if (typeof v !== 'number' || !Number.isSafeInteger(v) || v <= 0)
|
|
370
370
|
throw new Error(`E_REFRESH_OPTION_INVALID: ${name} must be a positive whole number`);
|
|
371
|
-
return v
|
|
371
|
+
return v;
|
|
372
|
+
}
|
|
373
|
+
function lifetimeMs(name, v) {
|
|
374
|
+
const n = positiveInt(name, v);
|
|
375
|
+
return n === undefined ? undefined : n * 1000;
|
|
372
376
|
}
|
|
373
377
|
/**
|
|
374
378
|
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
@@ -385,6 +389,7 @@ export class RefreshStore {
|
|
|
385
389
|
log;
|
|
386
390
|
maxAgeMs;
|
|
387
391
|
idleMs;
|
|
392
|
+
maxFamiliesPerSub;
|
|
388
393
|
macKey;
|
|
389
394
|
// Set once a load finds no earlier-release record. Nothing here adds one,
|
|
390
395
|
// so from then on a legacy-shaped token is refused before the lock.
|
|
@@ -394,6 +399,7 @@ export class RefreshStore {
|
|
|
394
399
|
this.masterKey = masterKey;
|
|
395
400
|
this.maxAgeMs = lifetimeMs('maxAgeSec', options.maxAgeSec);
|
|
396
401
|
this.idleMs = lifetimeMs('idleSec', options.idleSec);
|
|
402
|
+
this.maxFamiliesPerSub = positiveInt('maxFamiliesPerSub', options.maxFamiliesPerSub);
|
|
397
403
|
this.log = options.log ?? (() => undefined);
|
|
398
404
|
}
|
|
399
405
|
get timed() {
|
|
@@ -502,6 +508,47 @@ export class RefreshStore {
|
|
|
502
508
|
}
|
|
503
509
|
}
|
|
504
510
|
}
|
|
511
|
+
/** Evict `sub`'s own least-recently-used sessions (both formats) so at
|
|
512
|
+
* most `limit` of its live sessions remain; dropping one here is the same
|
|
513
|
+
* as deleting it from `data.families` / `data.active` on a revoke, so it
|
|
514
|
+
* can never be presented again (reuse detection for every OTHER family is
|
|
515
|
+
* untouched). Only `sub`'s own entries are ever inspected.
|
|
516
|
+
*
|
|
517
|
+
* `keepKey` is the family just issued: it is excluded from the ranking so
|
|
518
|
+
* a clock step back, or a concurrent rotate's `usedAt = max(usedAt, nowMs)`
|
|
519
|
+
* racing ahead of it under the lock, can never make the brand-new family
|
|
520
|
+
* look like the least recently used one and drop itself. */
|
|
521
|
+
enforceFamilyCap(data, sub, limit, lines, keepKey) {
|
|
522
|
+
const entries = [];
|
|
523
|
+
for (const [k, f] of Object.entries(data.families)) {
|
|
524
|
+
if (f.sub !== sub || k === keepKey)
|
|
525
|
+
continue;
|
|
526
|
+
entries.push({
|
|
527
|
+
at: f.usedAt,
|
|
528
|
+
drop: () => {
|
|
529
|
+
delete data.families[k];
|
|
530
|
+
lines.push(`refresh family dropped: cap tag=${familyTag(Buffer.from(k, 'base64url'))}`);
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
for (const [t, r] of Object.entries(data.active)) {
|
|
535
|
+
if (r.sub !== sub)
|
|
536
|
+
continue;
|
|
537
|
+
entries.push({
|
|
538
|
+
at: r.issuedAt,
|
|
539
|
+
drop: () => {
|
|
540
|
+
delete data.active[t];
|
|
541
|
+
lines.push(`refresh family dropped: cap tag=${familyTag(r.family)}`);
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
// +1 accounts for the kept family, which stays live but is never ranked.
|
|
546
|
+
if (entries.length + 1 <= limit)
|
|
547
|
+
return;
|
|
548
|
+
entries.sort((a, b) => a.at - b.at);
|
|
549
|
+
for (const e of entries.slice(0, entries.length + 1 - limit))
|
|
550
|
+
e.drop();
|
|
551
|
+
}
|
|
505
552
|
newFamilyId(data) {
|
|
506
553
|
let fid;
|
|
507
554
|
do
|
|
@@ -517,8 +564,11 @@ export class RefreshStore {
|
|
|
517
564
|
const lines = [];
|
|
518
565
|
const { data } = this.load(nowMs, lines);
|
|
519
566
|
const fid = this.newFamilyId(data);
|
|
567
|
+
const fidKey = fid.toString('base64url');
|
|
520
568
|
const { token, cur } = mintR1(key, fid, 0);
|
|
521
|
-
data.families[
|
|
569
|
+
data.families[fidKey] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
|
|
570
|
+
if (this.maxFamiliesPerSub !== undefined)
|
|
571
|
+
this.enforceFamilyCap(data, sub, this.maxFamiliesPerSub, lines, fidKey);
|
|
522
572
|
this.save(data);
|
|
523
573
|
this.emit(lines);
|
|
524
574
|
return token;
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -26,6 +26,10 @@ export interface AuthServerConfig {
|
|
|
26
26
|
/** `idleSec` of that store: sessions end this long after their last
|
|
27
27
|
* refresh. Unset: unlimited. */
|
|
28
28
|
refreshIdleSec?: number;
|
|
29
|
+
/** `maxFamiliesPerSub` of that store: bounds how many live refresh-token
|
|
30
|
+
* families one subject may hold, dropping its least-recently-used one to
|
|
31
|
+
* make room. Unset: unlimited. */
|
|
32
|
+
refreshMaxFamiliesPerSub?: number;
|
|
29
33
|
/** Default true. The clientless `flow=alias_reauth` link names an alias with
|
|
30
34
|
* no tenant binding, so a multi-tenant deployment sets false: with several
|
|
31
35
|
* tenants an alias name alone is ambiguous and the branch becomes a
|
|
@@ -103,7 +107,11 @@ export interface AuthServerDeps {
|
|
|
103
107
|
hd?: string;
|
|
104
108
|
}) => {
|
|
105
109
|
sub: string;
|
|
106
|
-
} | null
|
|
110
|
+
} | null | {
|
|
111
|
+
denied: {
|
|
112
|
+
description: string;
|
|
113
|
+
};
|
|
114
|
+
};
|
|
107
115
|
/** alias_add completion: persist the new alias's config row and tokens under
|
|
108
116
|
* its tenant. /callback awaits it only after a successful Google exchange.
|
|
109
117
|
* A returned refusal answers 403 with its slug; a throw or rejection answers
|