mcp-google-multi 6.0.0-alpha.54 → 6.0.0-alpha.55

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.
@@ -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
@@ -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', opts = {}) {
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;
@@ -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 {
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
+ }
@@ -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 serialize key. */
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;
@@ -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 KEY: a McpServer
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 uses a single key (one boot server); with resolveServer each subject
51
- // gets its own server and therefore its own independent lock lane.
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
- this.locks.set(key, run.then(() => undefined, () => undefined));
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 = { server: this.opts.server };
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 must match the SERVER identity, not the subject: without a
208
- // resolver every subject shares the one boot server, so keying on sub
209
- // would let two subjects race one server through the connect gap.
210
- const serializeKey = this.opts.resolveServer ? sub : '__single__';
211
- const mcpServer = target.server;
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, this.opts.validationEnvelope ?? {});
233
+ const enveloped = withValidationEnvelope(transport, validationEnvelope ?? {});
222
234
  const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
223
- await mcpServer.connect(this.opts.argShapeFor || this.opts.strictArgs
224
- ? withArgNormalization(tapped, this.opts.argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, this.opts.strictArgs)
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, getAccountSet } from './accounts.js';
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 { argNormalizationEnabled, withArgNormalization, withValidationEnvelope } from './arg-normalize.js';
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 registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }), undefined, stdioMetrics);
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
- const strictStdio = strictArgOptions(registry, stdioMetrics);
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
- argNormalizationEnabled() ? (n) => registry.argShape(n) : () => undefined, undefined, stdioMetrics ? (tool, n) => stdioMetrics.recordArgFix(tool, n) : undefined, strictStdio)
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,7 +241,8 @@ 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 registry = buildRegistry(httpServer, buildIdentityContext(process.env, { transport: 'http' }), 'curated', httpMetrics);
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.
@@ -345,14 +328,9 @@ async function main() {
345
328
  authenticate: authServer.authenticate,
346
329
  routes: authServer.routes,
347
330
  log: (l) => process.stderr.write(`[http] ${l}\n`),
348
- argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
331
+ ...requestHooksFor(registry, httpCtx, httpMetrics),
349
332
  metricsTap: httpTap,
350
- validationEnvelope: {
351
- isKnownTool: (n) => registry.hasTool(n),
352
- defaultAccount: () => getAccountSet().defaultAccount,
353
- },
354
333
  onArgRename: httpMetrics ? (tool, n) => httpMetrics.recordArgFix(tool, n) : undefined,
355
- strictArgs: strictArgOptions(registry, httpMetrics),
356
334
  });
357
335
  await host.start();
358
336
  process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.54",
3
+ "version": "6.0.0-alpha.55",
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",