mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3
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/accounts.d.ts +17 -1
- package/dist/accounts.js +52 -15
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +6 -5
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +34 -4
- package/dist/auth.d.ts +4 -3
- package/dist/auth.js +11 -13
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +117 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +37 -1
- package/dist/discover.js +3 -1
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +25 -2
- package/dist/doctor.d.ts +6 -2
- package/dist/doctor.js +119 -23
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +4 -2
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +44 -10
- package/dist/fs-atomic.d.ts +11 -0
- package/dist/fs-atomic.js +219 -28
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +64 -0
- package/dist/http-transport.d.ts +62 -4
- package/dist/http-transport.js +343 -24
- package/dist/identity.d.ts +4 -1
- package/dist/identity.js +25 -6
- package/dist/index.js +66 -138
- package/dist/master-key.js +13 -5
- package/dist/mcp-token.d.ts +136 -19
- package/dist/mcp-token.js +569 -72
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +103 -3
- package/dist/oauth-as.js +374 -79
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +16 -0
- package/dist/outbound-allowlist.js +5 -2
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +12 -8
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +65 -5
- package/dist/registry.d.ts +18 -1
- package/dist/registry.js +71 -15
- package/dist/scope-catalog.d.ts +11 -0
- package/dist/scope-catalog.js +23 -6
- package/dist/scope-observability.d.ts +8 -6
- package/dist/scope-observability.js +29 -13
- package/dist/services.d.ts +15 -10
- package/dist/services.js +59 -15
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +101 -62
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +67 -0
- package/dist/token-store.d.ts +20 -2
- 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/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +18 -0
- package/dist/tools/account-wizard.d.ts +10 -1
- package/dist/tools/account-wizard.js +74 -17
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +34 -18
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +15 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +13 -8
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +18 -14
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +13 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +17 -13
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +34 -30
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +65 -50
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +19 -12
- package/dist/tools/generated/_shared.d.ts +2 -1
- package/dist/tools/generated/_shared.js +7 -3
- package/dist/tools/generated/admin.d.ts +2 -1
- package/dist/tools/generated/admin.js +265 -265
- package/dist/tools/generated/analytics.d.ts +2 -1
- package/dist/tools/generated/analytics.js +125 -125
- package/dist/tools/generated/appsmarket.d.ts +2 -1
- package/dist/tools/generated/appsmarket.js +5 -5
- package/dist/tools/generated/calendar.d.ts +2 -1
- package/dist/tools/generated/calendar.js +55 -55
- package/dist/tools/generated/chat.d.ts +2 -1
- package/dist/tools/generated/chat.js +95 -95
- package/dist/tools/generated/classroom.d.ts +2 -1
- package/dist/tools/generated/classroom.js +209 -209
- package/dist/tools/generated/cloudidentity.d.ts +2 -1
- package/dist/tools/generated/cloudidentity.js +133 -133
- package/dist/tools/generated/cloudsearch.d.ts +2 -1
- package/dist/tools/generated/cloudsearch.js +99 -99
- package/dist/tools/generated/contacts.d.ts +2 -1
- package/dist/tools/generated/contacts.js +29 -29
- package/dist/tools/generated/drive.d.ts +2 -1
- package/dist/tools/generated/drive.js +71 -71
- package/dist/tools/generated/driveactivity.d.ts +2 -1
- package/dist/tools/generated/driveactivity.js +3 -3
- package/dist/tools/generated/drivelabels.d.ts +2 -1
- package/dist/tools/generated/drivelabels.js +53 -53
- package/dist/tools/generated/forms.d.ts +2 -1
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.d.ts +2 -1
- package/dist/tools/generated/gmail.js +117 -117
- package/dist/tools/generated/groupsmigration.d.ts +2 -1
- package/dist/tools/generated/groupsmigration.js +3 -3
- package/dist/tools/generated/groupssettings.d.ts +2 -1
- package/dist/tools/generated/groupssettings.js +7 -7
- package/dist/tools/generated/index.d.ts +2 -1
- package/dist/tools/generated/keep.d.ts +2 -1
- package/dist/tools/generated/keep.js +15 -15
- package/dist/tools/generated/licensing.d.ts +2 -1
- package/dist/tools/generated/licensing.js +15 -15
- package/dist/tools/generated/meet.d.ts +2 -1
- package/dist/tools/generated/meet.js +27 -27
- package/dist/tools/generated/postmaster.d.ts +2 -1
- package/dist/tools/generated/postmaster.js +9 -9
- package/dist/tools/generated/reseller.d.ts +2 -1
- package/dist/tools/generated/reseller.js +35 -35
- package/dist/tools/generated/script.d.ts +2 -1
- package/dist/tools/generated/script.js +33 -33
- package/dist/tools/generated/searchconsole.d.ts +2 -1
- package/dist/tools/generated/searchconsole.js +3 -3
- package/dist/tools/generated/sheets.d.ts +2 -1
- package/dist/tools/generated/sheets.js +15 -15
- package/dist/tools/generated/tasks.d.ts +2 -1
- package/dist/tools/generated/tasks.js +5 -5
- package/dist/tools/generated/vault.d.ts +2 -1
- package/dist/tools/generated/vault.js +67 -67
- package/dist/tools/generated/workspaceevents.d.ts +2 -1
- package/dist/tools/generated/workspaceevents.js +31 -31
- package/dist/tools/gmail.d.ts +2 -1
- package/dist/tools/gmail.js +45 -33
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +30 -16
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +13 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +17 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +36 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +17 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +19 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +20 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +65 -47
- package/dist/write-control.js +10 -2
- package/package.json +27 -1
package/dist/index.js
CHANGED
|
@@ -1,97 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// First import: triggers accounts.js module load (env files + registry) before
|
|
3
3
|
// anything else. Named to also pull the server-only empty-registry guard (BR-4).
|
|
4
|
-
import { assertServerAccountsConfigured
|
|
4
|
+
import { assertServerAccountsConfigured } from './accounts.js';
|
|
5
|
+
import { accountsAssertRequired, assertNoEnvAccountsMode, assertNoEnvOptionalScopesMode, isMultiTenantBoot, mtBootGates } from './boot-gates.js';
|
|
5
6
|
import { readFileSync } from 'node:fs';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
9
10
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
10
|
-
import {
|
|
11
|
-
import { GENERATED_GATES, SERVICES, unknownToolMessage } from './services.js';
|
|
12
|
-
import { ToolRegistry, resolveDiscoveryMode } from './registry.js';
|
|
13
|
-
import { registerDiscoverTools } from './discover.js';
|
|
14
|
-
import { registerEscapeTools } from './tools/google-api.js';
|
|
15
|
-
import { registerAccountTools } from './tools/accounts-tool.js';
|
|
16
|
-
import { registerDiagnoseTool } from './doctor.js';
|
|
17
|
-
import { registerAccountWizardTools } from './tools/account-wizard.js';
|
|
18
|
-
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
11
|
+
import { resolveDiscoveryMode } from './registry.js';
|
|
19
12
|
import { isAllowed, describePolicy } from './write-control.js';
|
|
20
13
|
import { buildIdentityContext } from './identity.js';
|
|
14
|
+
import { buildRegistry, requestHooksFor } from './compose.js';
|
|
21
15
|
import { registerSetupPrompt } from './setup-prompt.js';
|
|
22
16
|
import { applyNetTuning } from './net-tuning.js';
|
|
23
|
-
import {
|
|
24
|
-
import { unknownArgMode } from './arg-strict.js';
|
|
17
|
+
import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
|
|
25
18
|
import { envValueSource } from './env-load.js';
|
|
26
19
|
import { loadConfigFile } from './config-file.js';
|
|
27
20
|
import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
|
|
28
21
|
applyNetTuning();
|
|
29
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
23
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
31
|
-
/** Unknown-argument screening options, or undefined when the feature is off.
|
|
32
|
-
* Shared by both transports so a mistyped argument behaves identically. */
|
|
33
|
-
function strictArgOptions(registry, metrics) {
|
|
34
|
-
const mode = unknownArgMode();
|
|
35
|
-
if (mode === 'off')
|
|
36
|
-
return undefined;
|
|
37
|
-
return {
|
|
38
|
-
mode,
|
|
39
|
-
declaredFor: (tool) => registry.declaredKeys(tool),
|
|
40
|
-
siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
|
|
41
|
-
unknownTool: (name) => unknownToolMessage(registry, name),
|
|
42
|
-
onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
function buildRegistry(server, ctx, mode, metrics = null) {
|
|
46
|
-
const policy = ctx.policy;
|
|
47
|
-
const registry = new ToolRegistry(server, policy, mode, metrics);
|
|
48
|
-
const toolsets = getToolsets();
|
|
49
|
-
if (toolsets !== 'all') {
|
|
50
|
-
const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
|
|
51
|
-
for (const requested of toolsets) {
|
|
52
|
-
if (!known.has(requested)) {
|
|
53
|
-
process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
for (const svc of SERVICES) {
|
|
58
|
-
if (!toolsetEnabled(toolsets, svc.name))
|
|
59
|
-
continue;
|
|
60
|
-
if (svc.enabled && !svc.enabled()) {
|
|
61
|
-
if (toolsets !== 'all') {
|
|
62
|
-
const hint = svc.name === 'admin' ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS)' : `add "${svc.name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`;
|
|
63
|
-
process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
|
|
64
|
-
}
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
svc.register(registry);
|
|
68
|
-
}
|
|
69
|
-
for (const gen of GENERATED_SERVICES) {
|
|
70
|
-
if (!toolsetEnabled(toolsets, gen.name))
|
|
71
|
-
continue;
|
|
72
|
-
const curated = SERVICES.find((s) => s.name === gen.name);
|
|
73
|
-
const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
|
|
74
|
-
if (gate && !gate()) {
|
|
75
|
-
if (!curated && toolsets !== 'all') {
|
|
76
|
-
process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
|
|
77
|
-
}
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
gen.register(registry);
|
|
81
|
-
}
|
|
82
|
-
if (registry.services().length === 0) {
|
|
83
|
-
const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
|
|
84
|
-
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
85
|
-
`Known services: ${known.join(', ')}. ` +
|
|
86
|
-
`Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
|
|
87
|
-
}
|
|
88
|
-
registerDiscoverTools(registry, policy);
|
|
89
|
-
registerEscapeTools(registry, policy);
|
|
90
|
-
registerAccountTools(registry);
|
|
91
|
-
registerDiagnoseTool(registry);
|
|
92
|
-
registerAccountWizardTools(registry, server);
|
|
93
|
-
return registry;
|
|
94
|
-
}
|
|
95
24
|
async function main() {
|
|
96
25
|
if (process.argv.includes('auth')) {
|
|
97
26
|
const { runAuthFlow } = await import('./auth.js');
|
|
@@ -198,14 +127,8 @@ async function main() {
|
|
|
198
127
|
}
|
|
199
128
|
return;
|
|
200
129
|
}
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// (env / migrate-config / account import / auth) before the server runs.
|
|
204
|
-
assertServerAccountsConfigured();
|
|
205
|
-
// Resolve (or provision) the master key BEFORE serving: the hard guard is
|
|
206
|
-
// specced "fatal at startup", never mid-dispatch.
|
|
207
|
-
const { resolveMasterKey } = await import('./master-key.js');
|
|
208
|
-
resolveMasterKey();
|
|
130
|
+
// Transport derivation FIRST: the accounts gate below is transport-aware
|
|
131
|
+
// (a multi-tenant HTTP deploy legitimately boots with zero accounts).
|
|
209
132
|
const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
|
|
210
133
|
let httpCfg;
|
|
211
134
|
try {
|
|
@@ -217,6 +140,21 @@ async function main() {
|
|
|
217
140
|
}
|
|
218
141
|
const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
|
|
219
142
|
const wantHttp = transportIncludesHttp(httpCfg.transport);
|
|
143
|
+
// BR-4: the SERVER never boots with an empty registry — stdio always, HTTP
|
|
144
|
+
// when single-owner. The bootstrap and diagnostic CLIs above already
|
|
145
|
+
// returned. A multi-tenant boot instead refuses the two process-wide env
|
|
146
|
+
// vectors that would leak one operator's accounts/scopes into every tenant.
|
|
147
|
+
const mt = isMultiTenantBoot();
|
|
148
|
+
if (accountsAssertRequired(wantStdio, mt))
|
|
149
|
+
assertServerAccountsConfigured();
|
|
150
|
+
if (mt) {
|
|
151
|
+
assertNoEnvAccountsMode();
|
|
152
|
+
assertNoEnvOptionalScopesMode();
|
|
153
|
+
}
|
|
154
|
+
// Resolve (or provision) the master key BEFORE serving: the hard guard is
|
|
155
|
+
// specced "fatal at startup", never mid-dispatch.
|
|
156
|
+
const { resolveMasterKey } = await import('./master-key.js');
|
|
157
|
+
resolveMasterKey();
|
|
220
158
|
// Local usage metrics: fail-closed resolve, self-announcing source, null
|
|
221
159
|
// when off (no wrapper, no dir, nothing initializes). One instance per
|
|
222
160
|
// transport so `boots` keys stay honest under `both`.
|
|
@@ -254,40 +192,47 @@ async function main() {
|
|
|
254
192
|
if (wantStdio) {
|
|
255
193
|
const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
256
194
|
const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
|
|
257
|
-
const
|
|
195
|
+
const stdioCtx = buildIdentityContext(process.env, { transport: 'stdio' });
|
|
196
|
+
const registry = buildRegistry(server, stdioCtx, undefined, stdioMetrics);
|
|
258
197
|
registry.installListHandler();
|
|
259
198
|
registerSetupPrompt(server);
|
|
199
|
+
const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
|
|
260
200
|
// Outbound runs outermost-first, so the composition is deliberate: the
|
|
261
201
|
// envelope rewrite is INNERMOST (last to touch the frame), the tap sits
|
|
262
202
|
// above it (classifying the ORIGINAL validation prose), arg normalization
|
|
263
203
|
// outermost. The tap only counts outbound JSON-RPC error frames (no
|
|
264
204
|
// handler ran) plus id->tool names.
|
|
265
|
-
let transport = withValidationEnvelope(new StdioServerTransport(),
|
|
266
|
-
isKnownTool: (n) => registry.hasTool(n),
|
|
267
|
-
defaultAccount: () => getAccountSet().defaultAccount,
|
|
268
|
-
});
|
|
205
|
+
let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
|
|
269
206
|
if (stdioMetrics) {
|
|
270
207
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
271
208
|
transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
|
|
272
209
|
}
|
|
273
|
-
|
|
274
|
-
await server.connect(argNormalizationEnabled() || strictStdio
|
|
210
|
+
await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
|
|
275
211
|
? withArgNormalization(transport,
|
|
276
212
|
// Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
|
|
277
213
|
// shape unconditionally made stdio rename keys while
|
|
278
214
|
// GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
|
|
279
215
|
// and failed on HTTP once screening rejects.
|
|
280
|
-
|
|
216
|
+
stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
|
|
281
217
|
: transport);
|
|
282
218
|
}
|
|
283
219
|
if (wantHttp) {
|
|
284
220
|
const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
|
|
285
|
-
// BR3 / C13: the owner allowlist is the entire multi-tenant collapse; refuse
|
|
286
|
-
// to open an ungated HTTP endpoint.
|
|
287
221
|
const owners = parseOwnerEmails(process.env);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
222
|
+
const gates = mtBootGates();
|
|
223
|
+
if (gates?.multiTenant) {
|
|
224
|
+
// Same fail-fast SHAPE, different condition: an ungated HTTP endpoint
|
|
225
|
+
// must never boot; under tenancy the gate is "a valid provisioning
|
|
226
|
+
// mechanism exists", not a flat owner allowlist.
|
|
227
|
+
gates.assertProvisioningGate();
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// BR3 / C13: the owner allowlist is the entire multi-tenant collapse;
|
|
231
|
+
// refuse to open an ungated HTTP endpoint.
|
|
232
|
+
if (owners.length === 0) {
|
|
233
|
+
process.stderr.write('E_OWNER_EMAILS_REQUIRED: MCP_TRANSPORT includes http but MCP_OWNER_EMAILS is empty. Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.\n');
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
291
236
|
}
|
|
292
237
|
// BR7: stateless HTTP cannot push tools/list_changed, so it forces curated.
|
|
293
238
|
const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
|
|
@@ -296,33 +241,22 @@ async function main() {
|
|
|
296
241
|
}
|
|
297
242
|
const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
298
243
|
const httpMetrics = initMetricsFor('http', 'curated');
|
|
299
|
-
const
|
|
244
|
+
const httpCtx = buildIdentityContext(process.env, { transport: 'http' });
|
|
245
|
+
const registry = buildRegistry(httpServer, httpCtx, 'curated', httpMetrics);
|
|
300
246
|
registry.installListHandler();
|
|
301
247
|
registerSetupPrompt(httpServer);
|
|
302
248
|
// B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
|
|
303
|
-
const { buildAuthServer } = await import('./oauth-as.js');
|
|
249
|
+
const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
|
|
304
250
|
const { jwtSecretFrom } = await import('./mcp-token.js');
|
|
305
251
|
const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
|
|
306
252
|
const { OAuth2Client } = await import('googleapis-common');
|
|
307
|
-
const { writeToken } = await import('./token-store.js');
|
|
253
|
+
const { readToken, writeToken } = await import('./token-store.js');
|
|
308
254
|
const { resolveScopesForAccount } = await import('./auth.js');
|
|
255
|
+
const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
|
|
256
|
+
const { scopeGrantDiff, setWizardHttpConsent } = await import('./tools/account-wizard.js');
|
|
309
257
|
const { configDir } = await import('./config-file.js');
|
|
310
258
|
const { getAccountSet } = await import('./accounts.js');
|
|
311
259
|
const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
|
|
312
|
-
// Only trust the id_token email when Google marks it verified (#8).
|
|
313
|
-
const verifiedEmailFromIdToken = (idToken) => {
|
|
314
|
-
if (!idToken)
|
|
315
|
-
return undefined;
|
|
316
|
-
try {
|
|
317
|
-
const [, payload] = idToken.split('.');
|
|
318
|
-
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
319
|
-
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
320
|
-
return verified ? claims.email : undefined;
|
|
321
|
-
}
|
|
322
|
-
catch {
|
|
323
|
-
return undefined;
|
|
324
|
-
}
|
|
325
|
-
};
|
|
326
260
|
const authServer = buildAuthServer({
|
|
327
261
|
base: httpCfg.publicUrl,
|
|
328
262
|
resourceUri: httpCfg.resourceUri,
|
|
@@ -333,20 +267,7 @@ async function main() {
|
|
|
333
267
|
masterKey: resolveMasterKeyForDispatch().key,
|
|
334
268
|
refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
|
|
335
269
|
}, {
|
|
336
|
-
buildGoogleAuthUrl: ({ flow, alias, state }) => {
|
|
337
|
-
if (flow === 'alias_reauth' && alias) {
|
|
338
|
-
const cfg = getAccountSet().configs[alias];
|
|
339
|
-
// openid+email so /callback can bind the returned identity to the alias.
|
|
340
|
-
return googleClient().generateAuthUrl({
|
|
341
|
-
access_type: 'offline',
|
|
342
|
-
prompt: 'consent',
|
|
343
|
-
scope: [...new Set([...resolveScopesForAccount(alias), 'openid', 'email'])],
|
|
344
|
-
login_hint: cfg?.email,
|
|
345
|
-
state,
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
return googleClient().generateAuthUrl({ scope: ['openid', 'email'], prompt: 'select_account', state });
|
|
349
|
-
},
|
|
270
|
+
buildGoogleAuthUrl: ({ flow, alias, state }) => googleClient().generateAuthUrl(ownerAuthUrlOptions({ flow, alias, state }, (a) => resolveScopesForAccount(a))),
|
|
350
271
|
exchangeCode: async (code, flow) => {
|
|
351
272
|
const { tokens } = await googleClient().getToken(code);
|
|
352
273
|
const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
|
|
@@ -355,13 +276,25 @@ async function main() {
|
|
|
355
276
|
return { tokens: flow === 'owner_gate' ? {} : tokens, email };
|
|
356
277
|
},
|
|
357
278
|
writeToken: (alias, tokens) => writeToken(alias, tokens),
|
|
279
|
+
missingScopes: (alias, granted) => scopeGrantDiff(resolveScopesForAccount(alias), granted),
|
|
280
|
+
hasToken: (alias) => {
|
|
281
|
+
try {
|
|
282
|
+
return readToken(alias) != null;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return false; // undecryptable: nothing usable to keep
|
|
286
|
+
}
|
|
287
|
+
},
|
|
358
288
|
aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
|
|
359
289
|
log: (l) => process.stderr.write(`[as] ${l}\n`),
|
|
360
290
|
});
|
|
361
|
-
// BV-1: over HTTP, a dead/missing per-account token surfaces a clickable
|
|
362
|
-
// re-auth link into the AS's alias_reauth flow instead of a
|
|
363
|
-
const {
|
|
364
|
-
|
|
291
|
+
// BV-1: over HTTP, a dead/missing per-account token surfaces a clickable,
|
|
292
|
+
// signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
|
|
293
|
+
const { setHttpReauthLink } = await import('./reauth-hint.js');
|
|
294
|
+
setHttpReauthLink((alias) => authServer.reauthLink(alias));
|
|
295
|
+
// Wizard consent over HTTP: hand out the same signed link instead of
|
|
296
|
+
// binding a loopback listener.
|
|
297
|
+
setWizardHttpConsent({ mintConsentUrl: (alias) => authServer.reauthLink(alias) });
|
|
365
298
|
let httpTap;
|
|
366
299
|
if (httpMetrics) {
|
|
367
300
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
@@ -375,14 +308,9 @@ async function main() {
|
|
|
375
308
|
authenticate: authServer.authenticate,
|
|
376
309
|
routes: authServer.routes,
|
|
377
310
|
log: (l) => process.stderr.write(`[http] ${l}\n`),
|
|
378
|
-
|
|
311
|
+
...requestHooksFor(registry, httpCtx, httpMetrics),
|
|
379
312
|
metricsTap: httpTap,
|
|
380
|
-
validationEnvelope: {
|
|
381
|
-
isKnownTool: (n) => registry.hasTool(n),
|
|
382
|
-
defaultAccount: () => getAccountSet().defaultAccount,
|
|
383
|
-
},
|
|
384
313
|
onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
|
|
385
|
-
strictArgs: strictArgOptions(registry, httpMetrics),
|
|
386
314
|
});
|
|
387
315
|
await host.start();
|
|
388
316
|
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
package/dist/master-key.js
CHANGED
|
@@ -38,17 +38,25 @@ let keychainFactory = systemKeychain;
|
|
|
38
38
|
export function __setKeychainFactoryForTest(fn) {
|
|
39
39
|
keychainFactory = fn ?? systemKeychain;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
// (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
|
|
43
|
-
// one client) must still stop a fresh key from being minted.
|
|
44
|
-
function anyTokenFileExists() {
|
|
41
|
+
function anyEncFileUnder(dir) {
|
|
45
42
|
try {
|
|
46
|
-
return fs.readdirSync(
|
|
43
|
+
return fs.readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith('.enc'));
|
|
47
44
|
}
|
|
48
45
|
catch {
|
|
49
46
|
return false;
|
|
50
47
|
}
|
|
51
48
|
}
|
|
49
|
+
// Glob the token dir instead of iterating registered aliases: an orphan .enc
|
|
50
|
+
// (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
|
|
51
|
+
// one client) must still stop a fresh key from being minted. TOKEN_STORE_PATH
|
|
52
|
+
// may relocate the token dir outside configDir(), so scan it directly; the
|
|
53
|
+
// recursive configDir() walk then covers BOTH the tenant-namespaced token root
|
|
54
|
+
// (tenants/<id>) AND any top-level master-key-encrypted registry beside it
|
|
55
|
+
// (e.g. a downstream tenant map / invite store). Minting a fresh MASTER_KEY
|
|
56
|
+
// over any existing .enc bricks it irreversibly, so the scan must see them all.
|
|
57
|
+
function anyTokenFileExists() {
|
|
58
|
+
return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
|
|
59
|
+
}
|
|
52
60
|
function readKeyFile(filePath) {
|
|
53
61
|
try {
|
|
54
62
|
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const ACCESS_TTL_DEFAULT = 600;
|
|
2
2
|
export declare const STATE_TTL_DEFAULT = 600;
|
|
3
3
|
export declare const CODE_TTL_DEFAULT = 60;
|
|
4
|
-
export declare const REPLAY_CAP_DEFAULT =
|
|
4
|
+
export declare const REPLAY_CAP_DEFAULT = 100000;
|
|
5
5
|
/** Derive the HS256 secret (32 bytes) from the provisioned MCP_JWT_KEY string. */
|
|
6
6
|
export declare function jwtSecretFrom(jwtKey: string): Uint8Array;
|
|
7
7
|
export interface AccessTokenParams {
|
|
@@ -9,6 +9,7 @@ export interface AccessTokenParams {
|
|
|
9
9
|
secret: Uint8Array;
|
|
10
10
|
ttlSec?: number;
|
|
11
11
|
iat: number;
|
|
12
|
+
sub: string;
|
|
12
13
|
}
|
|
13
14
|
export declare function signAccessToken(p: AccessTokenParams): Promise<string>;
|
|
14
15
|
export interface AccessClaims {
|
|
@@ -19,63 +20,179 @@ export interface AccessClaims {
|
|
|
19
20
|
/** Verify an access token for `/mcp`. Throws on bad sig / aud / iss / exp. */
|
|
20
21
|
export declare function verifyAccessToken(token: string, base: string, secret: Uint8Array): Promise<AccessClaims>;
|
|
21
22
|
export interface StatePayload {
|
|
22
|
-
flow: 'owner_gate' | 'alias_reauth';
|
|
23
|
+
flow: 'owner_gate' | 'alias_reauth' | 'alias_add';
|
|
23
24
|
client_id: string;
|
|
24
25
|
redirect_uri: string;
|
|
25
26
|
code_challenge: string;
|
|
26
27
|
client_state?: string;
|
|
27
28
|
resource: string;
|
|
28
29
|
alias?: string;
|
|
30
|
+
/** alias_add only: the tenant the new alias binds under. Signed server-side
|
|
31
|
+
* at mint time — never caller-supplied at /authorize or /callback. */
|
|
32
|
+
tenantId?: string;
|
|
33
|
+
/** alias_add only: scope bundles chosen when the link was minted. */
|
|
34
|
+
bundles?: string[];
|
|
35
|
+
/** alias_add only: an opaque value the minting caller chose, signed like
|
|
36
|
+
* tenantId and handed back to its binder (e.g. a server-side link record). */
|
|
37
|
+
nonce?: string;
|
|
38
|
+
/** owner_gate only: base64url sha256 of the browser-binding cookie the
|
|
39
|
+
* Google redirect set; /callback refuses a browser that lacks it. */
|
|
40
|
+
bind?: string;
|
|
29
41
|
}
|
|
30
42
|
export interface CodePayload {
|
|
31
43
|
redirect_uri: string;
|
|
32
44
|
code_challenge: string;
|
|
33
45
|
resource: string;
|
|
34
|
-
sub:
|
|
46
|
+
sub: string;
|
|
35
47
|
}
|
|
36
48
|
export declare function signState(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
37
|
-
export declare function verifyState(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
49
|
+
export declare function verifyState(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
|
|
38
50
|
jti: string;
|
|
51
|
+
exp: number;
|
|
39
52
|
}>;
|
|
53
|
+
/** A re-auth link is handed out in a tool error, so it has to survive until
|
|
54
|
+
* the user clicks it; an hour, not the 10-minute state TTL. */
|
|
55
|
+
export declare const REAUTH_LINK_TTL_SEC = 3600;
|
|
56
|
+
/** Query string of a server-issued alias_reauth link: the alias, an expiry and
|
|
57
|
+
* an HMAC over both. Synchronous, so the error hints that carry it stay so. */
|
|
58
|
+
export declare function signReauthLink(base: string, secret: Uint8Array, alias: string, nowSec: number): string;
|
|
59
|
+
/** The alias a link was issued for, or null when it is missing, forged or expired. */
|
|
60
|
+
export declare function verifyReauthLink(base: string, secret: Uint8Array, params: {
|
|
61
|
+
alias: string;
|
|
62
|
+
exp: string;
|
|
63
|
+
sig: string;
|
|
64
|
+
}, nowSec: number): string | null;
|
|
40
65
|
/** A pending-authorization artifact for the DCR consent interstitial (same
|
|
41
66
|
* shape as `state`, distinct purpose so the two can't be confused). */
|
|
42
67
|
export declare function signPending(payload: StatePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
43
|
-
export declare function verifyPending(token: string, base: string, secret: Uint8Array): Promise<StatePayload & {
|
|
68
|
+
export declare function verifyPending(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<StatePayload & {
|
|
44
69
|
jti: string;
|
|
70
|
+
exp: number;
|
|
45
71
|
}>;
|
|
46
72
|
export declare function signAuthzCode(payload: CodePayload, base: string, secret: Uint8Array, iat: number, ttlSec?: number): Promise<string>;
|
|
47
|
-
export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array): Promise<CodePayload & {
|
|
73
|
+
export declare function verifyAuthzCode(token: string, base: string, secret: Uint8Array, nowSec?: number): Promise<CodePayload & {
|
|
48
74
|
jti: string;
|
|
75
|
+
exp: number;
|
|
49
76
|
}>;
|
|
77
|
+
/** `full`: there is no room without forgetting a jti that is still live. */
|
|
78
|
+
export type ReplaySpend = 'ok' | 'replay' | 'full';
|
|
50
79
|
export declare class ReplayGuard {
|
|
51
80
|
private readonly cap;
|
|
52
81
|
private readonly seen;
|
|
82
|
+
private earliest;
|
|
83
|
+
private sweptAt;
|
|
53
84
|
constructor(cap?: number);
|
|
54
|
-
/**
|
|
85
|
+
/** Hold a slot for an artifact about to be handed out, so its spend can
|
|
86
|
+
* never meet a full guard. The flood then lands where the artifact is
|
|
87
|
+
* minted, and refusing there loses nothing. */
|
|
88
|
+
reserve(jti: string, ttlMs: number, nowMs: number): 'ok' | 'full';
|
|
89
|
+
/** Record a jti as spent. A full guard refuses instead of evicting a live
|
|
90
|
+
* jti: a flood of spends would otherwise make a spent artifact usable again,
|
|
91
|
+
* such as a callback the browser binding refused (H4 F1). `artifactExpMs`
|
|
92
|
+
* keeps the record alive as long as the artifact itself, even when the clock
|
|
93
|
+
* stepped back between its signing and this spend. */
|
|
94
|
+
spend(jti: string, ttlMs: number, nowMs: number, artifactExpMs?: number): ReplaySpend;
|
|
95
|
+
/** Undo a spend whose next step could not go ahead. The slot stays held, so
|
|
96
|
+
* nothing is forgotten, and the artifact can be spent once more. */
|
|
97
|
+
unspend(jti: string): void;
|
|
98
|
+
/** `spend` as a boolean: false for a replay and for a full guard alike. */
|
|
55
99
|
consume(jti: string, ttlMs: number, nowMs: number): boolean;
|
|
56
100
|
get size(): number;
|
|
57
|
-
private
|
|
101
|
+
private record;
|
|
102
|
+
private room;
|
|
58
103
|
}
|
|
104
|
+
/** An earlier release's record, keyed by its live token. */
|
|
59
105
|
export interface RefreshRecord {
|
|
60
|
-
sub:
|
|
106
|
+
sub: string;
|
|
61
107
|
issuedAt: number;
|
|
62
108
|
family: string;
|
|
63
109
|
}
|
|
64
110
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
111
|
+
* The family tag of a refresh token this `masterKey`'s store minted, or null
|
|
112
|
+
* for anything else. Derives the key once; the returned function does no I/O,
|
|
113
|
+
* so a host can key a pre-auth rate limit on it.
|
|
114
|
+
*/
|
|
115
|
+
export declare function refreshFamilyTagger(masterKey: string): (token: string) => string | null;
|
|
116
|
+
/** Boot check for a refresh store file: returns when it is absent or this
|
|
117
|
+
* release can read it; throws when it does not decrypt under `masterKey`
|
|
118
|
+
* (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
|
|
119
|
+
* (E_REFRESH_STORE_FORMAT). */
|
|
120
|
+
export declare function assertRefreshStoreReadable(file: string, masterKey: string): void;
|
|
121
|
+
export interface RefreshStoreOptions {
|
|
122
|
+
/** Absolute family lifetime in seconds, counted from the sign-in. Unset: unlimited. */
|
|
123
|
+
maxAgeSec?: number;
|
|
124
|
+
/** Idle lifetime in seconds, counted from the last issue or rotation. Unset: unlimited. */
|
|
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;
|
|
134
|
+
/** Server-side log for a revoked, dropped or expired family. Never given a token or a subject. */
|
|
135
|
+
log?: (line: string) => void;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Persisted (encrypted under MASTER_KEY) refresh-token store. Rotates on every
|
|
139
|
+
* use, and a token carries its family and generation under a MAC, so
|
|
140
|
+
* presenting any generation the family has rotated past is REUSE: the family
|
|
141
|
+
* is revoked for the rest of its life (#7 / C14, H4 F2). The store keeps one
|
|
142
|
+
* record per family, whatever its rotation count. Every read-modify-write goes
|
|
143
|
+
* through a file lock so concurrent stdio+HTTP processes can't lost-update or
|
|
144
|
+
* double-spend (#12). See docs/internals.md.
|
|
70
145
|
*/
|
|
71
146
|
export declare class RefreshStore {
|
|
72
147
|
private readonly path;
|
|
73
148
|
private readonly masterKey;
|
|
74
|
-
|
|
149
|
+
private readonly log;
|
|
150
|
+
private readonly maxAgeMs;
|
|
151
|
+
private readonly idleMs;
|
|
152
|
+
private readonly maxFamiliesPerSub;
|
|
153
|
+
private macKey;
|
|
154
|
+
private legacyEmpty;
|
|
155
|
+
constructor(path: string, masterKey: string, options?: RefreshStoreOptions);
|
|
156
|
+
private get timed();
|
|
157
|
+
private live;
|
|
158
|
+
/** Drop expired sessions in both formats; the count of those dropped. */
|
|
159
|
+
private prune;
|
|
160
|
+
/** A session written while the clock ran ahead would otherwise outlive its
|
|
161
|
+
* limit by the size of the jump. */
|
|
162
|
+
private clamp;
|
|
163
|
+
private key;
|
|
164
|
+
/** With `nowMs` (every issue and rotation), also clamps and prunes. */
|
|
75
165
|
private load;
|
|
76
166
|
private save;
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
*
|
|
80
|
-
|
|
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;
|
|
179
|
+
private newFamilyId;
|
|
180
|
+
issue(nowMs: number, sub: string): string;
|
|
181
|
+
/** Rotate a presented refresh token; null if it is unknown, malformed, or
|
|
182
|
+
* rotated away (reuse => the family is revoked). The record's sub is copied
|
|
183
|
+
* forward and returned so the caller can mint the matching access token
|
|
184
|
+
* without trusting anything client-supplied.
|
|
185
|
+
*
|
|
186
|
+
* `accept` (optional) is called inside the store lock, before any mutation,
|
|
187
|
+
* with the record's sub. false: every session of that sub is dropped and
|
|
188
|
+
* null returned. A throw: nothing is written and the error propagates, so
|
|
189
|
+
* the presented token stays valid. Absent: unchanged. */
|
|
190
|
+
rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean): {
|
|
191
|
+
token: string;
|
|
192
|
+
sub: string;
|
|
193
|
+
} | null;
|
|
194
|
+
/** Drop every session minted under `sub`, in both formats (linear scan under
|
|
195
|
+
* the store lock); the save then drops the earlier-release spent entries of
|
|
196
|
+
* the families that died with them. Returns the number of sessions dropped. */
|
|
197
|
+
purgeTenant(sub: string): number;
|
|
81
198
|
}
|