mcp-google-multi 6.0.0-alpha.54 → 6.0.0-alpha.56
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 +1 -0
- package/dist/accounts.js +8 -5
- package/dist/auth.js +2 -2
- package/dist/compose.d.ts +6 -0
- package/dist/compose.js +26 -1
- package/dist/http-transport.d.ts +15 -6
- package/dist/http-transport.js +26 -14
- package/dist/index.js +14 -51
- package/dist/mcp-token.d.ts +3 -0
- package/dist/migrate-config.js +2 -2
- package/dist/oauth-as.d.ts +29 -11
- package/dist/oauth-as.js +32 -7
- package/dist/scope-catalog.d.ts +3 -0
- package/dist/scope-catalog.js +6 -1
- package/dist/tools/account-wizard.d.ts +1 -1
- package/dist/tools/account-wizard.js +2 -2
- package/package.json +3 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface AccountSet {
|
|
|
20
20
|
}
|
|
21
21
|
export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw', opts?: {
|
|
22
22
|
tokenDir?: string;
|
|
23
|
+
emptyOk?: boolean;
|
|
23
24
|
}): AccountSet;
|
|
24
25
|
export declare function fileStamp(filePath: string, version: number): string;
|
|
25
26
|
/** Live accessor: dispatch-time readers use this, never a captured snapshot. */
|
package/dist/accounts.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { loadEnvFiles } from './env-load.js';
|
|
5
5
|
import { ALIAS_RE, CONFIG_VERSION, configDir, configFilePath, failStartup, isReservedAlias, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
6
|
-
import {
|
|
6
|
+
import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
7
7
|
const envLoad = loadEnvFiles();
|
|
8
8
|
const defaultTokenDir = path.join(configDir(), 'tokens');
|
|
9
9
|
const tokenDir = process.env.TOKEN_STORE_PATH
|
|
@@ -74,7 +74,10 @@ function noAccountsMessage() {
|
|
|
74
74
|
* empty behaves as unset, mirroring GOOGLE_ACCOUNTS semantics.
|
|
75
75
|
*/
|
|
76
76
|
let warnedLegacy = false;
|
|
77
|
-
export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit',
|
|
77
|
+
export function resolveAccounts(env = process.env, filePath = configFilePath(), onInvalid = 'exit',
|
|
78
|
+
// emptyOk: zero accounts is a valid set, not an error, even with 'throw'
|
|
79
|
+
// (a context that has linked nothing yet). An invalid file still fails.
|
|
80
|
+
opts = {}) {
|
|
78
81
|
const dir = opts.tokenDir ?? tokenDir;
|
|
79
82
|
const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
|
|
80
83
|
const rawEnv = env.GOOGLE_ACCOUNTS;
|
|
@@ -92,7 +95,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
92
95
|
if (bundle === 'admin') {
|
|
93
96
|
fail('E_UNKNOWN_BUNDLE', '"admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
|
|
94
97
|
}
|
|
95
|
-
if (!(bundle
|
|
98
|
+
if (!isKnownBundle(bundle)) {
|
|
96
99
|
const hint = closestBundle(bundle);
|
|
97
100
|
fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
98
101
|
}
|
|
@@ -130,7 +133,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
130
133
|
// empty (BR-4), enforced by assertServerAccountsConfigured() in index.ts.
|
|
131
134
|
// The dispatch-path reload ('throw') still throws so a mid-session emptied
|
|
132
135
|
// config.json keeps the last-good registry (BR-7) instead of dropping tools.
|
|
133
|
-
if (onInvalid === 'throw') {
|
|
136
|
+
if (onInvalid === 'throw' && !opts.emptyOk) {
|
|
134
137
|
throw new Error(`E_NO_ACCOUNTS_CONFIGURED: ${noAccountsMessage()}`);
|
|
135
138
|
}
|
|
136
139
|
return {
|
|
@@ -149,7 +152,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
|
|
|
149
152
|
for (const [name, profile] of Object.entries(config?.scopeProfiles ?? {})) {
|
|
150
153
|
const bundles = resolveBundleAliases(profile.bundles);
|
|
151
154
|
for (const bundle of bundles) {
|
|
152
|
-
if (!(bundle
|
|
155
|
+
if (!isKnownBundle(bundle)) {
|
|
153
156
|
const hint = closestBundle(bundle);
|
|
154
157
|
fail('E_UNKNOWN_BUNDLE', `unknown bundle "${bundle}" in scope profile "${name}"${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
155
158
|
}
|
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { openUrl } from './open-url.js';
|
|
3
3
|
import { ACCOUNTS, getAccountSet } from './accounts.js';
|
|
4
|
-
import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
4
|
+
import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
5
5
|
import { resolveMasterKey } from './master-key.js';
|
|
6
6
|
import { writeToken } from './token-store.js';
|
|
7
7
|
import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
|
|
@@ -48,7 +48,7 @@ function legacyGlobalProfile() {
|
|
|
48
48
|
if (bundle === 'admin') {
|
|
49
49
|
throw new Error('E_UNKNOWN_BUNDLE: "admin" is not a global bundle: grant it per account via GOOGLE_ADMIN_ACCOUNTS or an "admin: true" scope profile.');
|
|
50
50
|
}
|
|
51
|
-
if (!(bundle
|
|
51
|
+
if (!isKnownBundle(bundle)) {
|
|
52
52
|
const hint = closestBundle(bundle);
|
|
53
53
|
throw new Error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}`);
|
|
54
54
|
}
|
package/dist/compose.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
import type { ServerTarget } from './http-transport.js';
|
|
2
3
|
import { ToolRegistry, type DiscoveryMode } from './registry.js';
|
|
3
4
|
import { type IdentityContext } from './identity.js';
|
|
4
5
|
import type { Metrics } from './usage-metrics.js';
|
|
5
6
|
export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
|
|
7
|
+
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
8
|
+
* argument screening and the account a validation envelope names. Both
|
|
9
|
+
* transports and every resolved server target build them here, so a mistyped
|
|
10
|
+
* argument behaves the same wherever it arrives. */
|
|
11
|
+
export declare function requestHooksFor(registry: ToolRegistry, ctx: Pick<IdentityContext, 'accounts'>, metrics?: Metrics | null, env?: NodeJS.ProcessEnv): Omit<ServerTarget, 'server'>;
|
package/dist/compose.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// parsing, no process.exit, no prints (accounts.js module-load resolution is
|
|
5
5
|
// the one inherited, pre-existing exception on the import chain).
|
|
6
6
|
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
7
|
-
import { GENERATED_GATES, SERVICES } from './services.js';
|
|
7
|
+
import { GENERATED_GATES, SERVICES, unknownToolMessage } from './services.js';
|
|
8
|
+
import { argNormalizationEnabled } from './arg-normalize.js';
|
|
9
|
+
import { unknownArgMode } from './arg-strict.js';
|
|
8
10
|
import { ToolRegistry } from './registry.js';
|
|
9
11
|
import { registerDiscoverTools } from './discover.js';
|
|
10
12
|
import { registerEscapeTools } from './tools/google-api.js';
|
|
@@ -82,3 +84,26 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
|
|
|
82
84
|
registerAccountWizardTools(registry, server);
|
|
83
85
|
return registry;
|
|
84
86
|
}
|
|
87
|
+
/** The per-request hooks bound to one registry: argument shapes, unknown-
|
|
88
|
+
* argument screening and the account a validation envelope names. Both
|
|
89
|
+
* transports and every resolved server target build them here, so a mistyped
|
|
90
|
+
* argument behaves the same wherever it arrives. */
|
|
91
|
+
export function requestHooksFor(registry, ctx, metrics = null, env = process.env) {
|
|
92
|
+
const mode = unknownArgMode(env);
|
|
93
|
+
return {
|
|
94
|
+
argShapeFor: argNormalizationEnabled(env) ? (tool) => registry.argShape(tool) : undefined,
|
|
95
|
+
strictArgs: mode === 'off'
|
|
96
|
+
? undefined
|
|
97
|
+
: {
|
|
98
|
+
mode,
|
|
99
|
+
declaredFor: (tool) => registry.declaredKeys(tool),
|
|
100
|
+
siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
|
|
101
|
+
unknownTool: (name) => unknownToolMessage(registry, name),
|
|
102
|
+
onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
|
|
103
|
+
},
|
|
104
|
+
validationEnvelope: {
|
|
105
|
+
isKnownTool: (name) => registry.hasTool(name),
|
|
106
|
+
defaultAccount: () => ctx.accounts.defaultAccount,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -15,6 +15,16 @@ export type AuthOutcome = {
|
|
|
15
15
|
export type Authenticator = (req: IncomingMessage) => AuthOutcome | Promise<AuthOutcome>;
|
|
16
16
|
/** A mounted extra route (the AS endpoints, B13). Return true if it wrote a response. */
|
|
17
17
|
export type RouteHandler = (req: IncomingMessage, res: ServerResponse, url: URL) => boolean | Promise<boolean>;
|
|
18
|
+
/** What a verified subject resolves to: ITS server plus the per-request hooks
|
|
19
|
+
* bound to that server's registry (tool shapes, declared keys, the default
|
|
20
|
+
* account a validation envelope names). The metrics observers (metricsTap,
|
|
21
|
+
* onArgRename) stay host-wide and must not be bound to any one registry. */
|
|
22
|
+
export interface ServerTarget {
|
|
23
|
+
server: McpServer;
|
|
24
|
+
argShapeFor?: (tool: string) => ArgShape | undefined;
|
|
25
|
+
strictArgs?: StrictArgOptions;
|
|
26
|
+
validationEnvelope?: ValidationEnvelopeOptions;
|
|
27
|
+
}
|
|
18
28
|
export interface HttpHostOptions {
|
|
19
29
|
/** The ONE McpServer + registry built at boot (P1 / BV gap #4: never per request). */
|
|
20
30
|
server: McpServer;
|
|
@@ -25,14 +35,13 @@ export interface HttpHostOptions {
|
|
|
25
35
|
/** Tenant-resolution seam: map the verified subject to ITS server. Absent
|
|
26
36
|
* (free core) = every request dispatches to the one boot `server`. Return
|
|
27
37
|
* null for an unknown subject -> 403 tenant_not_found. Each distinct
|
|
28
|
-
* resolved server gets its own
|
|
38
|
+
* resolved server gets its own dispatch lane. With a resolver, the
|
|
39
|
+
* per-request hooks come from the resolved target ONLY: the host-level
|
|
40
|
+
* argShapeFor / strictArgs / validationEnvelope describe the boot server's
|
|
41
|
+
* registry, never another subject's. */
|
|
29
42
|
resolveServer?: (claims: {
|
|
30
43
|
sub: string;
|
|
31
|
-
}) => Promise<
|
|
32
|
-
server: McpServer;
|
|
33
|
-
} | null> | {
|
|
34
|
-
server: McpServer;
|
|
35
|
-
} | null;
|
|
44
|
+
}) => Promise<ServerTarget | null> | ServerTarget | null;
|
|
36
45
|
/** Extra routes keyed by exact pathname (AS endpoints mount here in B13). */
|
|
37
46
|
routes?: Record<string, RouteHandler>;
|
|
38
47
|
log?: (line: string) => void;
|
package/dist/http-transport.js
CHANGED
|
@@ -43,12 +43,12 @@ export function jsonRpcMethod(body) {
|
|
|
43
43
|
export class HttpTransportHost {
|
|
44
44
|
opts;
|
|
45
45
|
httpServer;
|
|
46
|
-
// Serialize the connect→dispatch critical section PER
|
|
46
|
+
// Serialize the connect→dispatch critical section PER SERVER: a McpServer
|
|
47
47
|
// captures its transport per request (protocol.js), but the gap between
|
|
48
48
|
// connect() and the dispatch capturing it would still race under true
|
|
49
49
|
// concurrency, so all traffic to ONE server runs under one mutex. The free
|
|
50
|
-
// core
|
|
51
|
-
// gets its own
|
|
50
|
+
// core has one lane (the boot server); with resolveServer each resolved
|
|
51
|
+
// server gets its own, however subjects map onto servers.
|
|
52
52
|
locks = new Map();
|
|
53
53
|
constructor(opts) {
|
|
54
54
|
this.opts = opts;
|
|
@@ -56,7 +56,15 @@ export class HttpTransportHost {
|
|
|
56
56
|
serializeFor(key, fn) {
|
|
57
57
|
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
58
58
|
const run = prev.then(fn, fn);
|
|
59
|
-
|
|
59
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
60
|
+
this.locks.set(key, tail);
|
|
61
|
+
// An idle lane drops out, so the map holds only in-flight subjects rather
|
|
62
|
+
// than every subject ever seen. A request that chained on this tail has
|
|
63
|
+
// already replaced it, so the identity check never frees a live lane.
|
|
64
|
+
void tail.then(() => {
|
|
65
|
+
if (this.locks.get(key) === tail)
|
|
66
|
+
this.locks.delete(key);
|
|
67
|
+
});
|
|
60
68
|
return run;
|
|
61
69
|
}
|
|
62
70
|
log(line) {
|
|
@@ -154,7 +162,12 @@ export class HttpTransportHost {
|
|
|
154
162
|
const sub = auth.sub ?? 'owner';
|
|
155
163
|
// Tenant resolution (the seam EE fills): map the verified subject to ITS
|
|
156
164
|
// server. Absent resolver = the one boot server for every subject.
|
|
157
|
-
let target = {
|
|
165
|
+
let target = {
|
|
166
|
+
server: this.opts.server,
|
|
167
|
+
argShapeFor: this.opts.argShapeFor,
|
|
168
|
+
strictArgs: this.opts.strictArgs,
|
|
169
|
+
validationEnvelope: this.opts.validationEnvelope,
|
|
170
|
+
};
|
|
158
171
|
if (this.opts.resolveServer) {
|
|
159
172
|
try {
|
|
160
173
|
target = await this.opts.resolveServer({ sub });
|
|
@@ -204,12 +217,11 @@ export class HttpTransportHost {
|
|
|
204
217
|
// releases. (A shared initialized-state persists across stateless requests;
|
|
205
218
|
// benign for the single-owner design.)
|
|
206
219
|
const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
|
|
207
|
-
// The lock lane
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
await this.serializeFor(serializeKey, async () => {
|
|
220
|
+
// The lock lane is the SERVER identity, never the subject: two subjects
|
|
221
|
+
// reaching one server (the boot server, or a resolver mapping several
|
|
222
|
+
// subjects to one) would otherwise race it through the connect gap.
|
|
223
|
+
const { server: mcpServer, argShapeFor, strictArgs, validationEnvelope } = target;
|
|
224
|
+
await this.serializeFor(mcpServer, async () => {
|
|
213
225
|
const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
|
|
214
226
|
let timer;
|
|
215
227
|
const timedOut = new Promise((resolve) => {
|
|
@@ -218,10 +230,10 @@ export class HttpTransportHost {
|
|
|
218
230
|
});
|
|
219
231
|
// Same composition as the stdio leg: the envelope rewrite is innermost,
|
|
220
232
|
// so the tap above it still classifies the original validation prose.
|
|
221
|
-
const enveloped = withValidationEnvelope(transport,
|
|
233
|
+
const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
|
|
222
234
|
const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
|
|
223
|
-
await mcpServer.connect(
|
|
224
|
-
? withArgNormalization(tapped,
|
|
235
|
+
await mcpServer.connect(argShapeFor || strictArgs
|
|
236
|
+
? withArgNormalization(tapped, argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, strictArgs)
|
|
225
237
|
: tapped);
|
|
226
238
|
// Reflect the dispatch into a non-rejecting arm: if the deadline wins the
|
|
227
239
|
// race, an orphaned handler settling later must not surface as an unhandled
|
package/dist/index.js
CHANGED
|
@@ -1,42 +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
5
|
import { accountsAssertRequired, assertNoEnvAccountsMode, assertNoEnvOptionalScopesMode, isMultiTenantBoot, mtBootGates } from './boot-gates.js';
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
10
10
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
11
|
-
import { unknownToolMessage } from './services.js';
|
|
12
11
|
import { resolveDiscoveryMode } from './registry.js';
|
|
13
12
|
import { isAllowed, describePolicy } from './write-control.js';
|
|
14
13
|
import { buildIdentityContext } from './identity.js';
|
|
15
|
-
import { buildRegistry } from './compose.js';
|
|
14
|
+
import { buildRegistry, requestHooksFor } from './compose.js';
|
|
16
15
|
import { registerSetupPrompt } from './setup-prompt.js';
|
|
17
16
|
import { applyNetTuning } from './net-tuning.js';
|
|
18
|
-
import {
|
|
19
|
-
import { unknownArgMode } from './arg-strict.js';
|
|
17
|
+
import { withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
|
|
20
18
|
import { envValueSource } from './env-load.js';
|
|
21
19
|
import { loadConfigFile } from './config-file.js';
|
|
22
20
|
import { initUsageMetrics, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
|
|
23
21
|
applyNetTuning();
|
|
24
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
23
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
26
|
-
/** Unknown-argument screening options, or undefined when the feature is off.
|
|
27
|
-
* Shared by both transports so a mistyped argument behaves identically. */
|
|
28
|
-
function strictArgOptions(registry, metrics) {
|
|
29
|
-
const mode = unknownArgMode();
|
|
30
|
-
if (mode === 'off')
|
|
31
|
-
return undefined;
|
|
32
|
-
return {
|
|
33
|
-
mode,
|
|
34
|
-
declaredFor: (tool) => registry.declaredKeys(tool),
|
|
35
|
-
siblingsFor: (tool, keys) => registry.siblingSpellings(tool, keys),
|
|
36
|
-
unknownTool: (name) => unknownToolMessage(registry, name),
|
|
37
|
-
onDrop: metrics ? (tool, keys) => metrics.recordArgDrop(tool, keys) : undefined,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
24
|
async function main() {
|
|
41
25
|
if (process.argv.includes('auth')) {
|
|
42
26
|
const { runAuthFlow } = await import('./auth.js');
|
|
@@ -208,30 +192,28 @@ async function main() {
|
|
|
208
192
|
if (wantStdio) {
|
|
209
193
|
const server = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
210
194
|
const stdioMetrics = initMetricsFor('stdio', resolveDiscoveryMode());
|
|
211
|
-
const
|
|
195
|
+
const stdioCtx = buildIdentityContext(process.env, { transport: 'stdio' });
|
|
196
|
+
const registry = buildRegistry(server, stdioCtx, undefined, stdioMetrics);
|
|
212
197
|
registry.installListHandler();
|
|
213
198
|
registerSetupPrompt(server);
|
|
199
|
+
const stdioHooks = requestHooksFor(registry, stdioCtx, stdioMetrics);
|
|
214
200
|
// Outbound runs outermost-first, so the composition is deliberate: the
|
|
215
201
|
// envelope rewrite is INNERMOST (last to touch the frame), the tap sits
|
|
216
202
|
// above it (classifying the ORIGINAL validation prose), arg normalization
|
|
217
203
|
// outermost. The tap only counts outbound JSON-RPC error frames (no
|
|
218
204
|
// handler ran) plus id->tool names.
|
|
219
|
-
let transport = withValidationEnvelope(new StdioServerTransport(),
|
|
220
|
-
isKnownTool: (n) => registry.hasTool(n),
|
|
221
|
-
defaultAccount: () => getAccountSet().defaultAccount,
|
|
222
|
-
});
|
|
205
|
+
let transport = withValidationEnvelope(new StdioServerTransport(), stdioHooks.validationEnvelope);
|
|
223
206
|
if (stdioMetrics) {
|
|
224
207
|
const { tapUsageMetrics } = await import('./metrics-tap.js');
|
|
225
208
|
transport = tapUsageMetrics(transport, stdioMetrics, (n) => registry.hasTool(n));
|
|
226
209
|
}
|
|
227
|
-
|
|
228
|
-
await server.connect(argNormalizationEnabled() || strictStdio
|
|
210
|
+
await server.connect(stdioHooks.argShapeFor || stdioHooks.strictArgs
|
|
229
211
|
? withArgNormalization(transport,
|
|
230
212
|
// Gated exactly as the HTTP leg gates `argShapeFor`. Passing the
|
|
231
213
|
// shape unconditionally made stdio rename keys while
|
|
232
214
|
// GOOGLE_ARG_NORMALIZE=off, so the same call succeeded on stdio
|
|
233
215
|
// and failed on HTTP once screening rejects.
|
|
234
|
-
|
|
216
|
+
stdioHooks.argShapeFor ?? (() => undefined), undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, stdioHooks.strictArgs)
|
|
235
217
|
: transport);
|
|
236
218
|
}
|
|
237
219
|
if (wantHttp) {
|
|
@@ -259,11 +241,12 @@ async function main() {
|
|
|
259
241
|
}
|
|
260
242
|
const httpServer = new McpServer({ name: 'mcp-google-multi', version: pkg.version });
|
|
261
243
|
const httpMetrics = initMetricsFor('http', 'curated');
|
|
262
|
-
const
|
|
244
|
+
const httpCtx = buildIdentityContext(process.env, { transport: 'http' });
|
|
245
|
+
const registry = buildRegistry(httpServer, httpCtx, 'curated', httpMetrics);
|
|
263
246
|
registry.installListHandler();
|
|
264
247
|
registerSetupPrompt(httpServer);
|
|
265
248
|
// B13: mount the OAuth 2.1 AS (legs A + B) + the Bearer authenticator.
|
|
266
|
-
const { buildAuthServer } = await import('./oauth-as.js');
|
|
249
|
+
const { buildAuthServer, verifiedEmailFromIdToken } = await import('./oauth-as.js');
|
|
267
250
|
const { jwtSecretFrom } = await import('./mcp-token.js');
|
|
268
251
|
const { resolveJwtKey, resolveMasterKeyForDispatch } = await import('./master-key.js');
|
|
269
252
|
const { OAuth2Client } = await import('googleapis-common');
|
|
@@ -272,20 +255,6 @@ async function main() {
|
|
|
272
255
|
const { configDir } = await import('./config-file.js');
|
|
273
256
|
const { getAccountSet } = await import('./accounts.js');
|
|
274
257
|
const googleClient = () => new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${httpCfg.publicUrl}/callback`);
|
|
275
|
-
// Only trust the id_token email when Google marks it verified (#8).
|
|
276
|
-
const verifiedEmailFromIdToken = (idToken) => {
|
|
277
|
-
if (!idToken)
|
|
278
|
-
return undefined;
|
|
279
|
-
try {
|
|
280
|
-
const [, payload] = idToken.split('.');
|
|
281
|
-
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
282
|
-
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
283
|
-
return verified ? claims.email : undefined;
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
return undefined;
|
|
287
|
-
}
|
|
288
|
-
};
|
|
289
258
|
const authServer = buildAuthServer({
|
|
290
259
|
base: httpCfg.publicUrl,
|
|
291
260
|
resourceUri: httpCfg.resourceUri,
|
|
@@ -326,8 +295,7 @@ async function main() {
|
|
|
326
295
|
const { setHttpReauthBase } = await import('./reauth-hint.js');
|
|
327
296
|
setHttpReauthBase(httpCfg.publicUrl);
|
|
328
297
|
// Wizard consent over HTTP: hand out the clientless AS link instead of
|
|
329
|
-
// binding a loopback listener
|
|
330
|
-
// its own signed-mint context here).
|
|
298
|
+
// binding a loopback listener.
|
|
331
299
|
const { setWizardHttpConsent } = await import('./tools/account-wizard.js');
|
|
332
300
|
setWizardHttpConsent({
|
|
333
301
|
mintConsentUrl: (alias) => `${httpCfg.publicUrl}/authorize?flow=alias_reauth&alias=${encodeURIComponent(alias)}`,
|
|
@@ -345,14 +313,9 @@ async function main() {
|
|
|
345
313
|
authenticate: authServer.authenticate,
|
|
346
314
|
routes: authServer.routes,
|
|
347
315
|
log: (l) => process.stderr.write(`[http] ${l}\n`),
|
|
348
|
-
|
|
316
|
+
...requestHooksFor(registry, httpCtx, httpMetrics),
|
|
349
317
|
metricsTap: httpTap,
|
|
350
|
-
validationEnvelope: {
|
|
351
|
-
isKnownTool: (n) => registry.hasTool(n),
|
|
352
|
-
defaultAccount: () => getAccountSet().defaultAccount,
|
|
353
|
-
},
|
|
354
318
|
onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
|
|
355
|
-
strictArgs: strictArgOptions(registry, httpMetrics),
|
|
356
319
|
});
|
|
357
320
|
await host.start();
|
|
358
321
|
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
package/dist/mcp-token.d.ts
CHANGED
|
@@ -32,6 +32,9 @@ export interface StatePayload {
|
|
|
32
32
|
tenantId?: string;
|
|
33
33
|
/** alias_add only: scope bundles chosen when the link was minted. */
|
|
34
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;
|
|
35
38
|
}
|
|
36
39
|
export interface CodePayload {
|
|
37
40
|
redirect_uri: string;
|
package/dist/migrate-config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CONFIG_VERSION, configFilePath, loadConfigFile, mutateConfigFile } from './config-file.js';
|
|
2
|
-
import {
|
|
2
|
+
import { closestBundle, isKnownBundle, resolveBundleAliases } from './scope-catalog.js';
|
|
3
3
|
function parseCsv(value) {
|
|
4
4
|
return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
5
5
|
}
|
|
@@ -47,7 +47,7 @@ export function runMigrateConfig(env = process.env) {
|
|
|
47
47
|
process.exitCode = 1;
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
if (!(bundle
|
|
50
|
+
if (!isKnownBundle(bundle)) {
|
|
51
51
|
const hint = closestBundle(bundle);
|
|
52
52
|
console.error(`E_UNKNOWN_BUNDLE: unknown bundle "${bundle}" in GOOGLE_OPTIONAL_SCOPES${hint ? ` — did you mean "${hint}"?` : ''}. Nothing written.`);
|
|
53
53
|
process.exitCode = 1;
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -22,6 +22,27 @@ export interface AuthServerConfig {
|
|
|
22
22
|
* cross-tenant re-auth vector. Server-minted signed flows are unaffected. */
|
|
23
23
|
legacyAliasReauth?: boolean;
|
|
24
24
|
}
|
|
25
|
+
/** What /callback hands the alias_add binder once Google returned a verified
|
|
26
|
+
* identity for a server-minted link. */
|
|
27
|
+
export interface TenantAliasBind {
|
|
28
|
+
tenantId: string;
|
|
29
|
+
alias: string;
|
|
30
|
+
email: string;
|
|
31
|
+
bundles?: string[];
|
|
32
|
+
nonce?: string;
|
|
33
|
+
tokens: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
/** A binder's deliberate refusal (policy, not failure): /callback answers 403
|
|
36
|
+
* with this slug and message. The message reaches an unauthenticated browser. */
|
|
37
|
+
export interface AliasBindRefusal {
|
|
38
|
+
refused: {
|
|
39
|
+
slug: string;
|
|
40
|
+
message: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Only trust the id_token email when Google marks it verified (#8). The token
|
|
44
|
+
* comes straight from Google's token endpoint, so its claims are read unsigned. */
|
|
45
|
+
export declare function verifiedEmailFromIdToken(idToken?: string | null): string | undefined;
|
|
25
46
|
export interface AuthServerDeps {
|
|
26
47
|
fetchCimd?: (clientId: string) => Promise<Record<string, unknown>>;
|
|
27
48
|
/** Exchange a Google auth code at `${base}/callback` for tokens (+ owner email). */
|
|
@@ -34,6 +55,7 @@ export interface AuthServerDeps {
|
|
|
34
55
|
flow: StatePayload['flow'];
|
|
35
56
|
alias?: string;
|
|
36
57
|
state: string;
|
|
58
|
+
bundles?: string[];
|
|
37
59
|
}) => string;
|
|
38
60
|
writeToken?: (alias: string, tokens: Record<string, unknown>) => void;
|
|
39
61
|
registeredClients?: Map<string, {
|
|
@@ -52,16 +74,11 @@ export interface AuthServerDeps {
|
|
|
52
74
|
resolveSubject?: (email: string) => {
|
|
53
75
|
sub: string;
|
|
54
76
|
} | null;
|
|
55
|
-
/** alias_add completion: persist the config row
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
alias: string;
|
|
61
|
-
email: string;
|
|
62
|
-
bundles?: string[];
|
|
63
|
-
tokens: Record<string, unknown>;
|
|
64
|
-
}) => void;
|
|
77
|
+
/** alias_add completion: persist the new alias's config row and tokens under
|
|
78
|
+
* its tenant. /callback awaits it only after a successful Google exchange.
|
|
79
|
+
* A returned refusal answers 403 with its slug; a throw or rejection answers
|
|
80
|
+
* 500 E_ALIAS_ADD_FAILED. */
|
|
81
|
+
bindTenantAlias?: (bind: TenantAliasBind) => void | AliasBindRefusal | Promise<void | AliasBindRefusal>;
|
|
65
82
|
}
|
|
66
83
|
export interface AuthServer {
|
|
67
84
|
routes: Record<string, RouteHandler>;
|
|
@@ -69,12 +86,13 @@ export interface AuthServer {
|
|
|
69
86
|
/** Mint a signed, single-use, TTL-bound clientless flow URL
|
|
70
87
|
* (`${base}/authorize?flow=...&state=<signed>`). The tenantId/bundles ride
|
|
71
88
|
* INSIDE the signed state, so nothing at /authorize or /callback trusts a
|
|
72
|
-
* caller-supplied identity. Inert unless something calls it
|
|
89
|
+
* caller-supplied identity. Inert unless something calls it. */
|
|
73
90
|
mintFlowState: (opts: {
|
|
74
91
|
flow: 'alias_add' | 'alias_reauth';
|
|
75
92
|
alias: string;
|
|
76
93
|
tenantId?: string;
|
|
77
94
|
bundles?: string[];
|
|
95
|
+
nonce?: string;
|
|
78
96
|
}) => Promise<string>;
|
|
79
97
|
}
|
|
80
98
|
/** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
package/dist/oauth-as.js
CHANGED
|
@@ -17,6 +17,21 @@ const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
|
17
17
|
export const DCR_MAX_CLIENTS = 1000;
|
|
18
18
|
export const DCR_MAX_REDIRECT_URIS = 10;
|
|
19
19
|
const DCR_MAX_URI_LEN = 2048;
|
|
20
|
+
/** Only trust the id_token email when Google marks it verified (#8). The token
|
|
21
|
+
* comes straight from Google's token endpoint, so its claims are read unsigned. */
|
|
22
|
+
export function verifiedEmailFromIdToken(idToken) {
|
|
23
|
+
if (!idToken)
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const [, payload] = idToken.split('.');
|
|
27
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
|
|
28
|
+
const verified = claims.email_verified === true || claims.email_verified === 'true';
|
|
29
|
+
return verified ? claims.email : undefined;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
20
35
|
// --- helpers ----------------------------------------------------------------
|
|
21
36
|
function json(res, status, body, extraHeaders = {}) {
|
|
22
37
|
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
@@ -203,7 +218,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
203
218
|
async function toGoogle(res, sp) {
|
|
204
219
|
const state = await signState(sp, base, secret, nowSec());
|
|
205
220
|
const authUrl = deps.buildGoogleAuthUrl
|
|
206
|
-
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
|
|
221
|
+
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
|
|
207
222
|
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
208
223
|
return redirect(res, authUrl);
|
|
209
224
|
}
|
|
@@ -356,16 +371,26 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
356
371
|
const boundEmail = (exchanged.email ?? '').toLowerCase();
|
|
357
372
|
if (!boundEmail)
|
|
358
373
|
return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
|
|
374
|
+
let refusal;
|
|
359
375
|
try {
|
|
360
|
-
deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, tokens: exchanged.tokens });
|
|
376
|
+
const out = await deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, nonce: st.nonce, tokens: exchanged.tokens });
|
|
377
|
+
refusal = out?.refused;
|
|
361
378
|
}
|
|
362
379
|
catch (e) {
|
|
363
|
-
|
|
380
|
+
// Any rejection reason, even none, must still reach this answer.
|
|
381
|
+
log(`alias_add bind failed for "${st.alias}": ${e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection'}`);
|
|
364
382
|
return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
|
|
365
383
|
}
|
|
384
|
+
if (refusal) {
|
|
385
|
+
// The slug lands in a text/plain body; keep it a bare identifier.
|
|
386
|
+
const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
|
|
387
|
+
const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
|
|
388
|
+
log(`alias_add refused for "${st.alias}": ${slug}`);
|
|
389
|
+
return errorPage(res, 403, slug, message);
|
|
390
|
+
}
|
|
366
391
|
log(`callback ok flow=alias_add alias=${st.alias}`);
|
|
367
392
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
368
|
-
res.end(`<!doctype html><meta charset=utf-8><p>Connected "${st.alias}". You can close this window and retry your request.</p>`);
|
|
393
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
|
|
369
394
|
return true;
|
|
370
395
|
}
|
|
371
396
|
if (st.flow === 'alias_reauth') {
|
|
@@ -385,7 +410,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
385
410
|
deps.writeToken?.(st.alias, exchanged.tokens);
|
|
386
411
|
log(`callback ok flow=alias_reauth alias=${st.alias}`);
|
|
387
412
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
388
|
-
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${st.alias}". You can close this window.</p>`);
|
|
413
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>`);
|
|
389
414
|
return true;
|
|
390
415
|
}
|
|
391
416
|
// owner_gate: resolve the verified email to its subject (default = the
|
|
@@ -503,8 +528,8 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
503
528
|
return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
|
|
504
529
|
}
|
|
505
530
|
};
|
|
506
|
-
const mintFlowState = async ({ flow, alias, tenantId, bundles }) => {
|
|
507
|
-
const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles }, base, secret, nowSec());
|
|
531
|
+
const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
|
|
532
|
+
const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, secret, nowSec());
|
|
508
533
|
return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
|
|
509
534
|
};
|
|
510
535
|
return { routes, authenticate, mintFlowState };
|
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -14,6 +14,9 @@ export declare const BUNDLE_CATALOG: Record<string, BundleCatalogEntry>;
|
|
|
14
14
|
/** One-way soft-rename map (UD5): empty at 6.0.0; the mechanism ships now so a
|
|
15
15
|
* future rename is a deprecation warning, never a breaking config change. */
|
|
16
16
|
export declare const BUNDLE_ALIASES: Record<string, string>;
|
|
17
|
+
/** Bundle names are user input: an Object.prototype member ("constructor",
|
|
18
|
+
* "toString") passes a plain `in` or index lookup, so membership is own-key only. */
|
|
19
|
+
export declare function isKnownBundle(name: string): boolean;
|
|
17
20
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
18
21
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
19
22
|
export declare function closestBundle(name: string): string | undefined;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -160,9 +160,14 @@ export const BUNDLE_CATALOG = {
|
|
|
160
160
|
* future rename is a deprecation warning, never a breaking config change. */
|
|
161
161
|
export const BUNDLE_ALIASES = {};
|
|
162
162
|
const warnedAliases = new Set();
|
|
163
|
+
/** Bundle names are user input: an Object.prototype member ("constructor",
|
|
164
|
+
* "toString") passes a plain `in` or index lookup, so membership is own-key only. */
|
|
165
|
+
export function isKnownBundle(name) {
|
|
166
|
+
return Object.hasOwn(BUNDLE_CATALOG, name);
|
|
167
|
+
}
|
|
163
168
|
export function resolveBundleAliases(bundles) {
|
|
164
169
|
return bundles.map((name) => {
|
|
165
|
-
const target = BUNDLE_ALIASES[name];
|
|
170
|
+
const target = Object.hasOwn(BUNDLE_ALIASES, name) ? BUNDLE_ALIASES[name] : undefined;
|
|
166
171
|
if (!target)
|
|
167
172
|
return name;
|
|
168
173
|
if (!warnedAliases.has(name)) {
|
|
@@ -57,7 +57,7 @@ export declare function scopeGrantDiff(requested: string[], grantedScope: string
|
|
|
57
57
|
* present, runConsent NEVER binds a server-side loopback listener — the
|
|
58
58
|
* user's browser cannot reach the daemon's loopback — and instead hands out
|
|
59
59
|
* a clientless AS consent URL. The injector decides the flow (single-owner =
|
|
60
|
-
* the legacy alias_reauth link
|
|
60
|
+
* the legacy alias_reauth link). */
|
|
61
61
|
export interface WizardHttpConsent {
|
|
62
62
|
mintConsentUrl: (alias: string) => Promise<string> | string;
|
|
63
63
|
}
|
|
@@ -3,7 +3,7 @@ import { getAccountSet, invalidateAccountSet } from '../accounts.js';
|
|
|
3
3
|
import { ALIAS_RE, mutateConfigFile } from '../config-file.js';
|
|
4
4
|
import { writeToken } from '../token-store.js';
|
|
5
5
|
import { resolveScopesForAccount } from '../auth.js';
|
|
6
|
-
import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
|
|
6
|
+
import { BUNDLE_CATALOG, closestBundle, isKnownBundle, resolveBundleAliases } from '../scope-catalog.js';
|
|
7
7
|
import { openUrl } from '../open-url.js';
|
|
8
8
|
import { coerceBoolean } from './_coerce.js';
|
|
9
9
|
import { safeMessage, stringifyEnvelope } from './_errors.js';
|
|
@@ -58,7 +58,7 @@ export function validateAddForm(input, existingAliases) {
|
|
|
58
58
|
if (b === 'admin') {
|
|
59
59
|
return { ok: false, slug: 'E_UNKNOWN_BUNDLE', message: '"admin" is not a scope bundle.', hint: 'Use the admin checkbox instead, or pass "admin": true.', alias };
|
|
60
60
|
}
|
|
61
|
-
if (!(b
|
|
61
|
+
if (!isKnownBundle(b)) {
|
|
62
62
|
const closest = closestBundle(b);
|
|
63
63
|
const known = Object.keys(BUNDLE_CATALOG).filter((n) => n !== 'admin').join(', ');
|
|
64
64
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "6.0.0-alpha.
|
|
3
|
+
"version": "6.0.0-alpha.56",
|
|
4
4
|
"mcpName": "io.github.bakissation/mcp-google-multi",
|
|
5
5
|
"description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"./tenant-purge": "./dist/tenant-purge.js",
|
|
29
29
|
"./doctor": "./dist/doctor.js",
|
|
30
30
|
"./write-control": "./dist/write-control.js",
|
|
31
|
+
"./scope-catalog": "./dist/scope-catalog.js",
|
|
32
|
+
"./auth": "./dist/auth.js",
|
|
31
33
|
"./package.json": "./package.json"
|
|
32
34
|
},
|
|
33
35
|
"files": [
|