mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.4
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/README.md +5 -5
- package/dist/accounts.d.ts +20 -4
- package/dist/accounts.js +68 -44
- package/dist/api-probe.d.ts +5 -0
- package/dist/api-probe.js +20 -15
- package/dist/arg-normalize.js +19 -38
- package/dist/arg-strict.d.ts +7 -0
- package/dist/arg-strict.js +38 -28
- package/dist/auth.d.ts +5 -4
- package/dist/auth.js +16 -23
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +36 -0
- package/dist/cimd-cache.d.ts +38 -0
- package/dist/cimd-cache.js +111 -0
- package/dist/client-config.d.ts +2 -2
- package/dist/client-config.js +8 -14
- package/dist/client.d.ts +32 -2
- package/dist/client.js +0 -0
- package/dist/compose.d.ts +18 -0
- package/dist/compose.js +109 -0
- package/dist/config-file.d.ts +13 -0
- package/dist/config-file.js +41 -10
- package/dist/discover.js +7 -10
- package/dist/discovery-client.d.ts +3 -0
- package/dist/discovery-client.js +36 -32
- package/dist/doctor.d.ts +9 -5
- package/dist/doctor.js +119 -29
- package/dist/env-load.js +2 -5
- package/dist/executor.d.ts +3 -0
- package/dist/executor.js +6 -6
- package/dist/fanout.d.ts +9 -1
- package/dist/fanout.js +47 -22
- package/dist/fs-atomic.d.ts +8 -0
- package/dist/fs-atomic.js +216 -29
- package/dist/hint-provider.d.ts +129 -0
- package/dist/hint-provider.js +58 -0
- package/dist/http-config.d.ts +2 -2
- package/dist/http-config.js +3 -7
- package/dist/http-transport.d.ts +67 -9
- package/dist/http-transport.js +318 -52
- package/dist/identity.d.ts +6 -3
- package/dist/identity.js +24 -8
- package/dist/index.js +76 -160
- package/dist/master-key.d.ts +3 -3
- package/dist/master-key.js +16 -19
- package/dist/mcp-token.d.ts +126 -19
- package/dist/mcp-token.js +557 -76
- package/dist/metrics-cli.d.ts +1 -1
- package/dist/metrics-cli.js +1 -1
- package/dist/metrics-tap.js +2 -5
- package/dist/migrate-config.d.ts +1 -2
- package/dist/migrate-config.js +5 -7
- package/dist/net-tuning.js +2 -4
- package/dist/oauth-as.d.ts +104 -4
- package/dist/oauth-as.js +368 -114
- package/dist/oauth-consent.d.ts +10 -0
- package/dist/oauth-consent.js +20 -10
- package/dist/outbound-allowlist.js +9 -11
- package/dist/reauth-hint.d.ts +1 -1
- package/dist/reauth-hint.js +11 -10
- package/dist/registry-transfer.d.ts +5 -0
- package/dist/registry-transfer.js +63 -11
- package/dist/registry.d.ts +13 -4
- package/dist/registry.js +100 -72
- package/dist/scope-catalog.d.ts +16 -0
- package/dist/scope-catalog.js +40 -13
- package/dist/scope-observability.d.ts +10 -9
- package/dist/scope-observability.js +34 -24
- package/dist/services.d.ts +18 -10
- package/dist/services.js +61 -22
- package/dist/ssrf-guard.d.ts +23 -9
- package/dist/ssrf-guard.js +106 -78
- package/dist/tenant-purge.d.ts +26 -0
- package/dist/tenant-purge.js +65 -0
- package/dist/token-store.d.ts +20 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +4 -4
- package/dist/tools/_errors.d.ts +3 -0
- package/dist/tools/_errors.js +72 -60
- package/dist/tools/_local-files.d.ts +9 -0
- package/dist/tools/_local-files.js +22 -8
- package/dist/tools/account-wizard.d.ts +13 -3
- package/dist/tools/account-wizard.js +107 -56
- package/dist/tools/accounts-tool.d.ts +8 -1
- package/dist/tools/accounts-tool.js +35 -19
- package/dist/tools/admin.d.ts +10 -1
- package/dist/tools/admin.js +14 -11
- package/dist/tools/analytics.d.ts +10 -1
- package/dist/tools/analytics.js +14 -10
- package/dist/tools/calendar.d.ts +2 -1
- package/dist/tools/calendar.js +19 -18
- package/dist/tools/chat.d.ts +10 -1
- package/dist/tools/chat.js +12 -8
- package/dist/tools/contacts.d.ts +2 -1
- package/dist/tools/contacts.js +22 -19
- package/dist/tools/docs.d.ts +2 -1
- package/dist/tools/docs.js +35 -36
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +80 -74
- package/dist/tools/forms.d.ts +10 -1
- package/dist/tools/forms.js +18 -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-mime.d.ts +1 -1
- package/dist/tools/gmail-mime.js +7 -12
- package/dist/tools/gmail.d.ts +4 -3
- package/dist/tools/gmail.js +67 -69
- package/dist/tools/google-api.d.ts +2 -3
- package/dist/tools/google-api.js +34 -22
- package/dist/tools/meet.d.ts +10 -1
- package/dist/tools/meet.js +12 -9
- package/dist/tools/searchconsole.d.ts +2 -1
- package/dist/tools/searchconsole.js +16 -13
- package/dist/tools/sheets.d.ts +2 -1
- package/dist/tools/sheets.js +35 -32
- package/dist/tools/slides.d.ts +10 -1
- package/dist/tools/slides.js +16 -10
- package/dist/tools/tasks.d.ts +2 -1
- package/dist/tools/tasks.js +18 -15
- package/dist/trim.d.ts +5 -0
- package/dist/trim.js +19 -0
- package/dist/usage-metrics.d.ts +23 -0
- package/dist/usage-metrics.js +67 -56
- package/dist/write-control.d.ts +4 -8
- package/dist/write-control.js +13 -6
- package/package.json +47 -21
package/dist/index.js
CHANGED
|
@@ -1,97 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// First import: triggers accounts.js module load (env files + registry) before
|
|
3
|
-
// anything else. Named to also pull the server-only empty-registry guard
|
|
4
|
-
import { assertServerAccountsConfigured
|
|
3
|
+
// anything else. Named to also pull the server-only empty-registry guard.
|
|
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
|
-
import {
|
|
10
|
-
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
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';
|
|
10
|
+
import { resolveDiscoveryMode } from './registry.js';
|
|
19
11
|
import { isAllowed, describePolicy } from './write-control.js';
|
|
20
12
|
import { buildIdentityContext } from './identity.js';
|
|
13
|
+
import { buildRegistry, createMcpServer, requestHooksFor } from './compose.js';
|
|
21
14
|
import { registerSetupPrompt } from './setup-prompt.js';
|
|
22
15
|
import { applyNetTuning } from './net-tuning.js';
|
|
23
|
-
import {
|
|
24
|
-
import { unknownArgMode } from './arg-strict.js';
|
|
16
|
+
import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
|
|
25
17
|
import { envValueSource } from './env-load.js';
|
|
26
18
|
import { loadConfigFile } from './config-file.js';
|
|
27
19
|
import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
|
|
28
20
|
applyNetTuning();
|
|
29
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
22
|
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
23
|
async function main() {
|
|
96
24
|
if (process.argv.includes('auth')) {
|
|
97
25
|
const { runAuthFlow } = await import('./auth.js');
|
|
@@ -152,7 +80,7 @@ async function main() {
|
|
|
152
80
|
if (process.argv.includes('config') && process.argv.includes('check')) {
|
|
153
81
|
const ctx = buildIdentityContext();
|
|
154
82
|
const policy = ctx.policy;
|
|
155
|
-
const registry = buildRegistry(
|
|
83
|
+
const registry = buildRegistry(createMcpServer(pkg.version), ctx);
|
|
156
84
|
const cud = registry.tools.filter((t) => t.cud !== 'read');
|
|
157
85
|
const disabled = cud.filter((t) => !isAllowed(t, policy));
|
|
158
86
|
const counts = registry.visibleCount();
|
|
@@ -198,14 +126,8 @@ async function main() {
|
|
|
198
126
|
}
|
|
199
127
|
return;
|
|
200
128
|
}
|
|
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();
|
|
129
|
+
// Transport derivation FIRST: the accounts gate below is transport-aware
|
|
130
|
+
// (a multi-tenant HTTP deploy legitimately boots with zero accounts).
|
|
209
131
|
const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
|
|
210
132
|
let httpCfg;
|
|
211
133
|
try {
|
|
@@ -217,9 +139,20 @@ async function main() {
|
|
|
217
139
|
}
|
|
218
140
|
const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
|
|
219
141
|
const wantHttp = transportIncludesHttp(httpCfg.transport);
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
142
|
+
// The SERVER never boots with an empty registry (stdio always, HTTP when
|
|
143
|
+
// single-owner); a multi-tenant boot instead refuses the two process-wide env vectors.
|
|
144
|
+
const mt = isMultiTenantBoot();
|
|
145
|
+
if (accountsAssertRequired(wantStdio, mt))
|
|
146
|
+
assertServerAccountsConfigured();
|
|
147
|
+
if (mt) {
|
|
148
|
+
assertNoEnvAccountsMode();
|
|
149
|
+
assertNoEnvOptionalScopesMode();
|
|
150
|
+
}
|
|
151
|
+
// Resolve (or provision) the master key BEFORE serving: the hard guard is
|
|
152
|
+
// specced "fatal at startup", never mid-dispatch.
|
|
153
|
+
const { resolveMasterKey } = await import('./master-key.js');
|
|
154
|
+
resolveMasterKey();
|
|
155
|
+
// One Metrics instance per transport so `boots` keys stay honest under `both`.
|
|
223
156
|
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
224
157
|
const metricsState = resolveUsageMetrics(process.env, loadConfigFile()?.usageMetrics, envSrc?.kind === 'file' ? envSrc.file : undefined);
|
|
225
158
|
if (metricsState.warning)
|
|
@@ -249,80 +182,69 @@ async function main() {
|
|
|
249
182
|
});
|
|
250
183
|
}
|
|
251
184
|
}
|
|
252
|
-
// Build one McpServer + registry per transport at boot (
|
|
253
|
-
//
|
|
185
|
+
// Build one McpServer + registry per transport at boot (never
|
|
186
|
+
// rebuilt per request); `both` runs the two concurrently.
|
|
254
187
|
if (wantStdio) {
|
|
255
|
-
const server =
|
|
188
|
+
const server = createMcpServer(pkg.version);
|
|
256
189
|
const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
|
|
257
|
-
const
|
|
190
|
+
const stdioCtx = buildIdentityContext(process.env, { transport: 'stdio' });
|
|
191
|
+
const registry = buildRegistry(server, stdioCtx, undefined, stdioMetrics);
|
|
258
192
|
registry.installListHandler();
|
|
259
193
|
registerSetupPrompt(server);
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
// outermost. The tap only counts outbound JSON-RPC error frames (no
|
|
264
|
-
// handler ran) plus id->tool names.
|
|
265
|
-
let transport = withValidationEnvelope(new StdioServerTransport(), {
|
|
266
|
-
isKnownTool: (n) => registry.hasTool(n),
|
|
267
|
-
defaultAccount: () => getAccountSet().defaultAccount,
|
|
268
|
-
});
|
|
194
|
+
const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
|
|
195
|
+
// Composition order (outbound runs outermost-first): see docs/internals.md (Argument normalization).
|
|
196
|
+
let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
|
|
269
197
|
if (stdioMetrics) {
|
|
270
198
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
271
199
|
transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
|
|
272
200
|
}
|
|
273
|
-
|
|
274
|
-
await server.connect(argNormalizationEnabled() || strictStdio
|
|
201
|
+
await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
|
|
275
202
|
? withArgNormalization(transport,
|
|
276
|
-
// Gated exactly as the HTTP leg gates
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
// and failed on HTTP once screening rejects.
|
|
280
|
-
argNormalizationEnabled() ? (n) => registry.argShape(n) : () => undefined, undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, strictStdio)
|
|
203
|
+
// Gated exactly as the HTTP leg gates argShapeFor, or GOOGLE_ARG_NORMALIZE=off
|
|
204
|
+
// would rename keys on stdio while HTTP's screening rejects the same call.
|
|
205
|
+
stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
|
|
281
206
|
: transport);
|
|
282
207
|
}
|
|
283
208
|
if (wantHttp) {
|
|
284
209
|
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
210
|
const owners = parseOwnerEmails(process.env);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
211
|
+
const gates = mtBootGates();
|
|
212
|
+
if (gates?.multiTenant) {
|
|
213
|
+
// Same fail-fast shape as below, but the gate is "a valid provisioning
|
|
214
|
+
// mechanism exists", not a flat owner allowlist.
|
|
215
|
+
gates.assertProvisioningGate();
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
// The owner allowlist is the entire multi-tenant collapse;
|
|
219
|
+
// refuse to open an ungated HTTP endpoint.
|
|
220
|
+
if (owners.length === 0) {
|
|
221
|
+
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');
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
291
224
|
}
|
|
292
|
-
//
|
|
225
|
+
// Stateless HTTP cannot push tools/list_changed, so it forces curated.
|
|
293
226
|
const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
|
|
294
227
|
if (configuredMode && configuredMode !== 'curated') {
|
|
295
228
|
process.stderr.write(`GOOGLE_DISCOVERY="${configuredMode}" is ignored over HTTP; the stateless transport forces "curated".\n`);
|
|
296
229
|
}
|
|
297
|
-
const httpServer =
|
|
230
|
+
const httpServer = createMcpServer(pkg.version);
|
|
298
231
|
const httpMetrics = initMetricsFor('http', 'curated');
|
|
299
|
-
const
|
|
232
|
+
const httpCtx = buildIdentityContext(process.env, { transport: 'http' });
|
|
233
|
+
const registry = buildRegistry(httpServer, httpCtx, 'curated', httpMetrics);
|
|
300
234
|
registry.installListHandler();
|
|
301
235
|
registerSetupPrompt(httpServer);
|
|
302
|
-
//
|
|
303
|
-
const { buildAuthServer } = await import('./oauth-as.js');
|
|
236
|
+
// Mount the OAuth 2.1 AS (MCP-client tokens + owner login) + the Bearer authenticator.
|
|
237
|
+
const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
|
|
304
238
|
const { jwtSecretFrom } = await import('./mcp-token.js');
|
|
305
239
|
const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
|
|
306
240
|
const { OAuth2Client } = await import('googleapis-common');
|
|
307
|
-
const { writeToken } = await import('./token-store.js');
|
|
241
|
+
const { readToken, writeToken } = await import('./token-store.js');
|
|
308
242
|
const { resolveScopesForAccount } = await import('./auth.js');
|
|
243
|
+
const { ownerAuthUrlOptions } = await import('./oauth-consent.js');
|
|
244
|
+
const { scopeGrantDiff, setWizardHttpConsent } = await import('./tools/account-wizard.js');
|
|
309
245
|
const { configDir } = await import('./config-file.js');
|
|
310
246
|
const { getAccountSet } = await import('./accounts.js');
|
|
311
247
|
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
248
|
const authServer = buildAuthServer({
|
|
327
249
|
base: httpCfg.publicUrl,
|
|
328
250
|
resourceUri: httpCfg.resourceUri,
|
|
@@ -333,20 +255,7 @@ async function main() {
|
|
|
333
255
|
masterKey: resolveMasterKeyForDispatch().key,
|
|
334
256
|
refreshStorePath: path.join(configDir(), 'mcp-tokens.enc'),
|
|
335
257
|
}, {
|
|
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
|
-
},
|
|
258
|
+
buildGoogleAuthUrl: ({ flow, alias, state }) => googleClient().generateAuthUrl(ownerAuthUrlOptions({ flow, alias, state }, (a) => resolveScopesForAccount(a))),
|
|
350
259
|
exchangeCode: async (code, flow) => {
|
|
351
260
|
const { tokens } = await googleClient().getToken(code);
|
|
352
261
|
const email = verifiedEmailFromIdToken(tokens.id_token ?? undefined);
|
|
@@ -355,13 +264,25 @@ async function main() {
|
|
|
355
264
|
return { tokens: flow === 'owner_gate' ? {} : tokens, email };
|
|
356
265
|
},
|
|
357
266
|
writeToken: (alias, tokens) => writeToken(alias, tokens),
|
|
267
|
+
missingScopes: (alias, granted) => scopeGrantDiff(resolveScopesForAccount(alias), granted),
|
|
268
|
+
hasToken: (alias) => {
|
|
269
|
+
try {
|
|
270
|
+
return readToken(alias) != null;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false; // undecryptable: nothing usable to keep
|
|
274
|
+
}
|
|
275
|
+
},
|
|
358
276
|
aliasEmail: (alias) => getAccountSet().configs[alias]?.email,
|
|
359
277
|
log: (l) => process.stderr.write(`[as] ${l}\n`),
|
|
360
278
|
});
|
|
361
|
-
//
|
|
362
|
-
// re-auth link into the AS's alias_reauth flow instead of a
|
|
363
|
-
const {
|
|
364
|
-
|
|
279
|
+
// Over HTTP, a dead/missing per-account token surfaces a clickable,
|
|
280
|
+
// signed re-auth link into the AS's alias_reauth flow instead of a CLI hint.
|
|
281
|
+
const { setHttpReauthLink } = await import('./reauth-hint.js');
|
|
282
|
+
setHttpReauthLink((alias) => authServer.reauthLink(alias));
|
|
283
|
+
// Wizard consent over HTTP: hand out the same signed link instead of
|
|
284
|
+
// binding a loopback listener.
|
|
285
|
+
setWizardHttpConsent({ mintConsentUrl: (alias) => authServer.reauthLink(alias) });
|
|
365
286
|
let httpTap;
|
|
366
287
|
if (httpMetrics) {
|
|
367
288
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
@@ -375,18 +296,13 @@ async function main() {
|
|
|
375
296
|
authenticate: authServer.authenticate,
|
|
376
297
|
routes: authServer.routes,
|
|
377
298
|
log: (l) => process.stderr.write(`[http] ${l}\n`),
|
|
378
|
-
|
|
299
|
+
...requestHooksFor(registry, httpCtx, httpMetrics),
|
|
379
300
|
metricsTap: httpTap,
|
|
380
|
-
validationEnvelope: {
|
|
381
|
-
isKnownTool: (n) => registry.hasTool(n),
|
|
382
|
-
defaultAccount: () => getAccountSet().defaultAccount,
|
|
383
|
-
},
|
|
384
301
|
onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
|
|
385
|
-
strictArgs: strictArgOptions(registry, httpMetrics),
|
|
386
302
|
});
|
|
387
303
|
await host.start();
|
|
388
304
|
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
|
389
|
-
// Graceful shutdown so `docker run --init`
|
|
305
|
+
// Graceful shutdown so `docker run --init` forwards SIGTERM cleanly.
|
|
390
306
|
const shutdown = () => {
|
|
391
307
|
host.close().finally(() => process.exit(0));
|
|
392
308
|
};
|
package/dist/master-key.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface KeyDeps {
|
|
|
15
15
|
hasAnyToken?: () => boolean;
|
|
16
16
|
}
|
|
17
17
|
export declare function __setKeychainFactoryForTest(fn: ((account: string) => KeychainEntry | null) | null): void;
|
|
18
|
-
/**
|
|
18
|
+
/** Single choke point: no other module may read the env vars directly.
|
|
19
19
|
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
20
20
|
export declare function resolveMasterKey(deps?: KeyDeps): ResolvedKey;
|
|
21
21
|
/** Dispatch form: identical resolution, but failures THROW (caught by the tool
|
|
@@ -28,14 +28,14 @@ export declare function peekMasterKeyProvenance(deps?: KeyDeps): KeyProvenance |
|
|
|
28
28
|
* outstanding MCP tokens and clients re-auth silently. */
|
|
29
29
|
export declare function resolveJwtKey(deps?: KeyDeps): ResolvedKey;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* After the FIRST successful token decrypt with an env-sourced key,
|
|
32
32
|
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
33
33
|
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
34
34
|
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
35
35
|
* never fatal.
|
|
36
36
|
*/
|
|
37
37
|
export declare function noteSuccessfulDecrypt(): void;
|
|
38
|
-
/**
|
|
38
|
+
/** Reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
39
39
|
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
40
40
|
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
41
41
|
* have wiped every .enc token first — regenerating with tokens present would
|
package/dist/master-key.js
CHANGED
|
@@ -5,10 +5,8 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import { configDir, failStartup } from './config-file.js';
|
|
6
6
|
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
7
7
|
import { getTokenDir } from './accounts.js';
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// failure (no prebuild, native load error, headless Linux without a Secret
|
|
11
|
-
// Service) as "keychain unavailable", falling through to the 0600 file.
|
|
8
|
+
// Native optionalDependency, loaded lazily and synchronously (the token
|
|
9
|
+
// store's key read is sync); EVERY failure falls through to the 0600 file.
|
|
12
10
|
const require_ = createRequire(import.meta.url);
|
|
13
11
|
function systemKeychain(account) {
|
|
14
12
|
try {
|
|
@@ -32,23 +30,24 @@ function systemKeychain(account) {
|
|
|
32
30
|
}
|
|
33
31
|
}
|
|
34
32
|
// Default keychain factory, replaceable ONLY for tests: unit tests must never
|
|
35
|
-
// read or write the developer's real OS keyring
|
|
36
|
-
// test fixture key into a real GNOME keyring — never again).
|
|
33
|
+
// read or write the developer's real OS keyring.
|
|
37
34
|
let keychainFactory = systemKeychain;
|
|
38
35
|
export function __setKeychainFactoryForTest(fn) {
|
|
39
36
|
keychainFactory = fn ?? systemKeychain;
|
|
40
37
|
}
|
|
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() {
|
|
38
|
+
function anyEncFileUnder(dir) {
|
|
45
39
|
try {
|
|
46
|
-
return fs.readdirSync(
|
|
40
|
+
return fs.readdirSync(dir, { recursive: true }).some((f) => String(f).endsWith('.enc'));
|
|
47
41
|
}
|
|
48
42
|
catch {
|
|
49
43
|
return false;
|
|
50
44
|
}
|
|
51
45
|
}
|
|
46
|
+
// Globs the token dir rather than iterating registered aliases, so an orphan
|
|
47
|
+
// .enc still stops a fresh key from being minted. See docs/internals.md.
|
|
48
|
+
function anyTokenFileExists() {
|
|
49
|
+
return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
|
|
50
|
+
}
|
|
52
51
|
function readKeyFile(filePath) {
|
|
53
52
|
try {
|
|
54
53
|
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
|
@@ -90,7 +89,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
90
89
|
const fromFile = readKeyFile(filePath);
|
|
91
90
|
if (fromFile)
|
|
92
91
|
return { key: fromFile, provenance: 'file' };
|
|
93
|
-
// Generate-on-setup. The hard guard
|
|
92
|
+
// Generate-on-setup. The hard guard: minting a fresh
|
|
94
93
|
// MASTER_KEY while <alias>.enc files exist would silently brick them all.
|
|
95
94
|
if (hardGuard && hasAnyToken()) {
|
|
96
95
|
fail('E_MASTER_KEY_MISSING_TOKENS_EXIST', `no ${envName} found in env, keychain or ${filePath}, but encrypted tokens exist under ${getTokenDir()}. ` +
|
|
@@ -107,10 +106,8 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
107
106
|
return { key: racedFile, provenance: 'file' };
|
|
108
107
|
// Exactly randomBytes(32) base64: hits deriveKey's 32-byte fast path.
|
|
109
108
|
const generated = randomBytes(32).toString('base64');
|
|
110
|
-
// The 0600 file is ALWAYS written
|
|
111
|
-
//
|
|
112
|
-
// lives only there bricks every token at the next boot. The file is the
|
|
113
|
-
// durable sink; byte-exact read-back gates any use of the key.
|
|
109
|
+
// The 0600 file is ALWAYS written (some keychain backends don't survive
|
|
110
|
+
// a reboot); byte-exact read-back gates any use of the key.
|
|
114
111
|
atomicWriteFileSync(filePath, `${generated}\n`, 0o600);
|
|
115
112
|
if (readKeyFile(filePath) !== generated) {
|
|
116
113
|
fail('E_KEY_PROVISIONING_FAILED', `generated ${envName} could not be read back byte-exact from ${filePath}; refusing to encrypt anything with an unverified key.`);
|
|
@@ -143,7 +140,7 @@ function resolveKey(envName, fileName, hardGuard, deps, onFail = 'exit') {
|
|
|
143
140
|
}
|
|
144
141
|
let cachedMaster = null;
|
|
145
142
|
let cachedJwt = null;
|
|
146
|
-
/**
|
|
143
|
+
/** Single choke point: no other module may read the env vars directly.
|
|
147
144
|
* Boot/CLI form: failures exit with a clean stderr slug ("fatal at startup"). */
|
|
148
145
|
export function resolveMasterKey(deps) {
|
|
149
146
|
if (deps)
|
|
@@ -181,7 +178,7 @@ export function resolveJwtKey(deps) {
|
|
|
181
178
|
}
|
|
182
179
|
let mirrored = false;
|
|
183
180
|
/**
|
|
184
|
-
*
|
|
181
|
+
* After the FIRST successful token decrypt with an env-sourced key,
|
|
185
182
|
* mirror it into the keychain (only when the keychain holds no entry) so the
|
|
186
183
|
* key survives an env loss. Gating on a successful decrypt means a stale or
|
|
187
184
|
* wrong env key is never mirrored. Best-effort: failures are doctor material,
|
|
@@ -203,7 +200,7 @@ export function noteSuccessfulDecrypt() {
|
|
|
203
200
|
// Keychain backend unavailable; env stays the working source.
|
|
204
201
|
}
|
|
205
202
|
}
|
|
206
|
-
/**
|
|
203
|
+
/** Reset: drop the generated MASTER_KEY material (0600 file + keychain
|
|
207
204
|
* entry) so the next run re-provisions. Never removes an env-provided key (the
|
|
208
205
|
* deployer owns that; `env:true` flags it so reset can warn). The caller MUST
|
|
209
206
|
* have wiped every .enc token first — regenerating with tokens present would
|