mcp-google-multi 6.0.0-alpha.40 → 6.0.0-alpha.42

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.
@@ -0,0 +1,26 @@
1
+ export interface MtBootGates {
2
+ /** True = multi-tenant deployment: the server boots with zero accounts
3
+ * (tenants provision in-band) and the provisioning gate below replaces the
4
+ * flat owner-allowlist requirement. */
5
+ multiTenant: boolean;
6
+ /** Fail-fast replacement for the E_OWNER_EMAILS_REQUIRED check. MUST refuse
7
+ * (exit/throw) when no valid provisioning mechanism is configured — an
8
+ * ungated HTTP endpoint must never boot, whatever the condition checked. */
9
+ assertProvisioningGate: () => void;
10
+ }
11
+ export declare function setMtBootGates(g: MtBootGates | null): void;
12
+ export declare function mtBootGates(): MtBootGates | null;
13
+ export declare function isMultiTenantBoot(): boolean;
14
+ /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
15
+ * multi-tenant HTTP deploy starts empty by design. */
16
+ export declare function accountsAssertRequired(wantStdio: boolean, multiTenant: boolean): boolean;
17
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
18
+ * the env registry is process-wide, so it would graft one operator's accounts
19
+ * onto every tenant and turn every tenant's first wizard call into a dead
20
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
21
+ * of one clear one. */
22
+ export declare function assertNoEnvAccountsMode(env?: NodeJS.ProcessEnv): void;
23
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
24
+ * global scope profile, so on a multi-tenant box one operator's scope choice
25
+ * would silently apply to every tenant's consent. */
26
+ export declare function assertNoEnvOptionalScopesMode(env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,38 @@
1
+ // S1.16: multi-tenant boot gates. The free core never sets these — every
2
+ // helper reduces to today's single-owner behavior when no gates are
3
+ // installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
4
+ // resolve env at import time) so the tenancy module can import it cheaply.
5
+ import { failStartup } from './config-file.js';
6
+ let gates = null;
7
+ export function setMtBootGates(g) {
8
+ gates = g;
9
+ }
10
+ export function mtBootGates() {
11
+ return gates;
12
+ }
13
+ export function isMultiTenantBoot() {
14
+ return gates?.multiTenant === true;
15
+ }
16
+ /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
17
+ * multi-tenant HTTP deploy starts empty by design. */
18
+ export function accountsAssertRequired(wantStdio, multiTenant) {
19
+ return wantStdio || !multiTenant;
20
+ }
21
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
22
+ * the env registry is process-wide, so it would graft one operator's accounts
23
+ * onto every tenant and turn every tenant's first wizard call into a dead
24
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
25
+ * of one clear one. */
26
+ export function assertNoEnvAccountsMode(env = process.env) {
27
+ if (env.GOOGLE_ACCOUNTS?.trim()) {
28
+ failStartup('E_ENV_ACCOUNTS_MODE_MT', 'GOOGLE_ACCOUNTS must not be set on a multi-tenant deployment; accounts are linked in-band per tenant. Unset it and restart.');
29
+ }
30
+ }
31
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
32
+ * global scope profile, so on a multi-tenant box one operator's scope choice
33
+ * would silently apply to every tenant's consent. */
34
+ export function assertNoEnvOptionalScopesMode(env = process.env) {
35
+ if (env.GOOGLE_OPTIONAL_SCOPES?.trim()) {
36
+ failStartup('E_ENV_OPTIONAL_SCOPES_MT', 'GOOGLE_OPTIONAL_SCOPES must not be set on a multi-tenant deployment; scopes are chosen per tenant at consent. Unset it and restart.');
37
+ }
38
+ }
package/dist/client.d.ts CHANGED
@@ -1,3 +1,24 @@
1
1
  import { OAuth2Client } from 'googleapis-common';
2
- import type { Account } from './accounts.js';
3
- export declare function getClient(account: Account): Promise<OAuth2Client>;
2
+ import type { Account, AccountSet } from './accounts.js';
3
+ import type { TokenData } from './types.js';
4
+ export interface GetClientDeps {
5
+ /** Live account view — read at call time, never a captured snapshot. */
6
+ accounts: () => AccountSet;
7
+ readToken: (alias: string) => TokenData | null;
8
+ updateToken: (alias: string, updates: object) => void;
9
+ /** Cross-process staleness probe (single-owner global registry only). */
10
+ refreshIfStale?: () => void;
11
+ }
12
+ /** Test-only visibility into the dedupe map. */
13
+ export declare function __inflightSizeForTest(): number;
14
+ /**
15
+ * Per-context client factory: `contextKey` namespaces the single-flight map
16
+ * (one lane per tenant), `deps` carry that context's account view and token
17
+ * custody. The free core builds exactly one, bound to the global registry.
18
+ */
19
+ export declare function makeGetClient(contextKey: string, deps: GetClientDeps): (account: Account) => Promise<OAuth2Client>;
20
+ /** The single-owner client, bound to the global registry + token store. The
21
+ * operator surfaces that never carry a tenant (api-probe, executor and
22
+ * escape-hatch `deps.getClientFn ?? getClient` defaults, stdio dispatch)
23
+ * resolve through this. */
24
+ export declare const getClient: (account: Account) => Promise<OAuth2Client>;
package/dist/client.js CHANGED
Binary file
@@ -22,6 +22,17 @@ export interface HttpHostOptions {
22
22
  version: string;
23
23
  ownerConfigured: boolean;
24
24
  authenticate: Authenticator;
25
+ /** Tenant-resolution seam: map the verified subject to ITS server. Absent
26
+ * (free core) = every request dispatches to the one boot `server`. Return
27
+ * null for an unknown subject -> 403 tenant_not_found. Each distinct
28
+ * resolved server gets its own serialize key. */
29
+ resolveServer?: (claims: {
30
+ sub: string;
31
+ }) => Promise<{
32
+ server: McpServer;
33
+ } | null> | {
34
+ server: McpServer;
35
+ } | null;
25
36
  /** Extra routes keyed by exact pathname (AS endpoints mount here in B13). */
26
37
  routes?: Record<string, RouteHandler>;
27
38
  log?: (line: string) => void;
@@ -53,9 +64,9 @@ export declare function jsonRpcMethod(body: unknown): string;
53
64
  export declare class HttpTransportHost {
54
65
  private readonly opts;
55
66
  private httpServer?;
56
- private lock;
67
+ private readonly locks;
57
68
  constructor(opts: HttpHostOptions);
58
- private serialize;
69
+ private serializeFor;
59
70
  private log;
60
71
  start(): Promise<void>;
61
72
  close(): Promise<void>;
@@ -43,18 +43,20 @@ export function jsonRpcMethod(body) {
43
43
  export class HttpTransportHost {
44
44
  opts;
45
45
  httpServer;
46
- // Serialize the connect→dispatch critical section: the shared McpServer
46
+ // Serialize the connect→dispatch critical section PER KEY: 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
- // concurrency. Single-owner HTTP traffic is effectively serial, so a mutex
50
- // keeps correctness at negligible cost (and never rebuilds the registry).
51
- lock = Promise.resolve();
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.
52
+ locks = new Map();
52
53
  constructor(opts) {
53
54
  this.opts = opts;
54
55
  }
55
- serialize(fn) {
56
- const run = this.lock.then(fn, fn);
57
- this.lock = run.then(() => undefined, () => undefined);
56
+ serializeFor(key, fn) {
57
+ const prev = this.locks.get(key) ?? Promise.resolve();
58
+ const run = prev.then(fn, fn);
59
+ this.locks.set(key, run.then(() => undefined, () => undefined));
58
60
  return run;
59
61
  }
60
62
  log(line) {
@@ -149,6 +151,24 @@ export class HttpTransportHost {
149
151
  this.log(`${auth.status} auth_failed path=/mcp`);
150
152
  return;
151
153
  }
154
+ const sub = auth.sub ?? 'owner';
155
+ // Tenant resolution (the seam EE fills): map the verified subject to ITS
156
+ // server. Absent resolver = the one boot server for every subject.
157
+ let target = { server: this.opts.server };
158
+ if (this.opts.resolveServer) {
159
+ try {
160
+ target = await this.opts.resolveServer({ sub });
161
+ }
162
+ catch {
163
+ target = null;
164
+ }
165
+ if (!target) {
166
+ res.writeHead(403, { 'Content-Type': 'application/json' });
167
+ res.end(JSON.stringify({ error: 'tenant_not_found', message: 'no tenant is provisioned for this subject' }));
168
+ this.log('403 tenant_not_found path=/mcp');
169
+ return;
170
+ }
171
+ }
152
172
  // Thread the verified subject to tool handlers: the Node transport forwards
153
173
  // req.auth verbatim as ctx.http.authInfo. Token/clientId stay empty — the
154
174
  // bearer value must not re-enter the dispatch path via handler context.
@@ -156,7 +176,7 @@ export class HttpTransportHost {
156
176
  token: '',
157
177
  clientId: '',
158
178
  scopes: ['mcp:use'],
159
- extra: { sub: auth.sub ?? 'owner' },
179
+ extra: { sub },
160
180
  };
161
181
  let body;
162
182
  try {
@@ -184,7 +204,12 @@ export class HttpTransportHost {
184
204
  // releases. (A shared initialized-state persists across stateless requests;
185
205
  // benign for the single-owner design.)
186
206
  const deadlineMs = this.opts.dispatchTimeoutMs ?? DISPATCH_TIMEOUT_DEFAULT;
187
- await this.serialize(async () => {
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 () => {
188
213
  const disconnected = new Promise((resolve) => res.once('close', () => resolve('closed')));
189
214
  let timer;
190
215
  const timedOut = new Promise((resolve) => {
@@ -195,7 +220,7 @@ export class HttpTransportHost {
195
220
  // so the tap above it still classifies the original validation prose.
196
221
  const enveloped = withValidationEnvelope(transport, this.opts.validationEnvelope ?? {});
197
222
  const tapped = this.opts.metricsTap ? this.opts.metricsTap(enveloped) : enveloped;
198
- await this.opts.server.connect(this.opts.argShapeFor || this.opts.strictArgs
223
+ await mcpServer.connect(this.opts.argShapeFor || this.opts.strictArgs
199
224
  ? withArgNormalization(tapped, this.opts.argShapeFor ?? (() => undefined), this.opts.log, this.opts.onArgRename, this.opts.strictArgs)
200
225
  : tapped);
201
226
  // Reflect the dispatch into a non-rejecting arm: if the deadline wins the
package/dist/identity.js CHANGED
@@ -1,8 +1,12 @@
1
- import { getAccountSet } from './accounts.js';
2
- import { getClient } from './client.js';
3
- import { hasToken, readToken, updateToken, writeToken } from './token-store.js';
1
+ import { getAccountSet, getTokenDir, refreshAccountSetIfStale } from './accounts.js';
2
+ import { makeGetClient } from './client.js';
3
+ import { createTokenStore } from './token-store.js';
4
4
  import { resolvePolicy } from './write-control.js';
5
5
  export function buildIdentityContext(env = process.env, opts = {}) {
6
+ // Real closures over the single-owner state (the SAME factories EE
7
+ // instantiates per tenant), bound here to the global registry + token dir —
8
+ // behavior-identical to the module-level functions for the free core.
9
+ const tokenStore = createTokenStore(getTokenDir());
6
10
  return {
7
11
  subject: 'owner',
8
12
  // Live getter: the seam must always see the current registry, never a
@@ -13,7 +17,12 @@ export function buildIdentityContext(env = process.env, opts = {}) {
13
17
  // The dispatch transport rides into the resolved policy as a reserved seam
14
18
  // (cc-write-control B14); it does not change any write-control verdict.
15
19
  policy: resolvePolicy(env, { transport: opts.transport }),
16
- getClient,
17
- tokenStore: { readToken, writeToken, updateToken, hasToken },
20
+ getClient: makeGetClient('owner', {
21
+ accounts: getAccountSet,
22
+ readToken: tokenStore.readToken,
23
+ updateToken: tokenStore.updateToken,
24
+ refreshIfStale: refreshAccountSetIfStale,
25
+ }),
26
+ tokenStore,
18
27
  };
19
28
  }
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
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
4
  import { assertServerAccountsConfigured, getAccountSet } 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';
@@ -198,14 +199,8 @@ async function main() {
198
199
  }
199
200
  return;
200
201
  }
201
- // BR-4: the SERVER never boots with an empty registry. The bootstrap and
202
- // diagnostic CLIs above already returned; a fresh user configures accounts
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();
202
+ // Transport derivation FIRST: the accounts gate below is transport-aware
203
+ // (a multi-tenant HTTP deploy legitimately boots with zero accounts).
209
204
  const { resolveHttpConfig, transportIncludesHttp } = await import('./http-config.js');
210
205
  let httpCfg;
211
206
  try {
@@ -217,6 +212,21 @@ async function main() {
217
212
  }
218
213
  const wantStdio = httpCfg.transport === 'stdio' || httpCfg.transport === 'both';
219
214
  const wantHttp = transportIncludesHttp(httpCfg.transport);
215
+ // BR-4: the SERVER never boots with an empty registry — stdio always, HTTP
216
+ // when single-owner. The bootstrap and diagnostic CLIs above already
217
+ // returned. A multi-tenant boot instead refuses the two process-wide env
218
+ // vectors that would leak one operator's accounts/scopes into every tenant.
219
+ const mt = isMultiTenantBoot();
220
+ if (accountsAssertRequired(wantStdio, mt))
221
+ assertServerAccountsConfigured();
222
+ if (mt) {
223
+ assertNoEnvAccountsMode();
224
+ assertNoEnvOptionalScopesMode();
225
+ }
226
+ // Resolve (or provision) the master key BEFORE serving: the hard guard is
227
+ // specced "fatal at startup", never mid-dispatch.
228
+ const { resolveMasterKey } = await import('./master-key.js');
229
+ resolveMasterKey();
220
230
  // Local usage metrics: fail-closed resolve, self-announcing source, null
221
231
  // when off (no wrapper, no dir, nothing initializes). One instance per
222
232
  // transport so `boots` keys stay honest under `both`.
@@ -282,12 +292,21 @@ async function main() {
282
292
  }
283
293
  if (wantHttp) {
284
294
  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
295
  const owners = parseOwnerEmails(process.env);
288
- if (owners.length === 0) {
289
- 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');
290
- process.exit(1);
296
+ const gates = mtBootGates();
297
+ if (gates?.multiTenant) {
298
+ // Same fail-fast SHAPE, different condition: an ungated HTTP endpoint
299
+ // must never boot; under tenancy the gate is "a valid provisioning
300
+ // mechanism exists", not a flat owner allowlist.
301
+ gates.assertProvisioningGate();
302
+ }
303
+ else {
304
+ // BR3 / C13: the owner allowlist is the entire multi-tenant collapse;
305
+ // refuse to open an ungated HTTP endpoint.
306
+ if (owners.length === 0) {
307
+ 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');
308
+ process.exit(1);
309
+ }
291
310
  }
292
311
  // BR7: stateless HTTP cannot push tools/list_changed, so it forces curated.
293
312
  const configuredMode = (process.env.GOOGLE_DISCOVERY ?? '').trim().toLowerCase();
@@ -7,3 +7,20 @@ export declare function readToken(alias: string): TokenData | null;
7
7
  export declare function writeToken(alias: string, data: object): void;
8
8
  export declare function updateToken(alias: string, updates: object): void;
9
9
  export declare function hasToken(alias: string): boolean;
10
+ export interface TokenStore {
11
+ readToken(alias: string): TokenData | null;
12
+ writeToken(alias: string, data: object): void;
13
+ updateToken(alias: string, updates: object): void;
14
+ hasToken(alias: string): boolean;
15
+ }
16
+ /**
17
+ * Context-scoped token store: `.enc` files live under `dir`, ciphertext keyed
18
+ * by `tenantId` when present (HKDF v2 envelope; omitted = the single-owner v1
19
+ * envelope). Same lock + atomic-write + error contract as the module-level
20
+ * functions: readToken returns null ONLY when the file is missing and throws
21
+ * on a decrypt failure.
22
+ */
23
+ export declare function createTokenStore(dir: string, opts?: {
24
+ tenantId?: string;
25
+ resolveKey?: () => string;
26
+ }): TokenStore;
Binary file
@@ -44,7 +44,7 @@ export const KNOWN_ERROR_SLUGS = new Set([
44
44
  'insufficient_scope', 'internal', 'invalid_client', 'invalid_client_metadata',
45
45
  'invalid_grant', 'invalid_params', 'invalid_query', 'invalid_request',
46
46
  'invalid_scope', 'network_error', 'not_found', 'rate_limited',
47
- 'reauth_required', 'recipient_not_allowed', 'too_large', 'toolset_disabled', 'unknown_api',
47
+ 'reauth_required', 'recipient_not_allowed', 'tenant_not_found', 'too_large', 'toolset_disabled', 'unknown_api',
48
48
  'unknown_argument', 'unknown_method', 'unsupported_grant_type', 'unsupported_type',
49
49
  'untrusted_host', 'upstream_error', 'validation_error', 'write_disabled',
50
50
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.40",
3
+ "version": "6.0.0-alpha.42",
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",