mcp-google-multi 6.0.0-alpha.38 → 6.0.0-alpha.39

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.
@@ -18,7 +18,10 @@ export interface AccountSet {
18
18
  defaultAccount?: string;
19
19
  defaultAccountSource?: 'env' | 'config';
20
20
  }
21
- export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw'): AccountSet;
21
+ export declare function resolveAccounts(env?: NodeJS.ProcessEnv, filePath?: string, onInvalid?: 'exit' | 'throw', opts?: {
22
+ tokenDir?: string;
23
+ }): AccountSet;
24
+ export declare function fileStamp(filePath: string, version: number): string;
22
25
  /** Live accessor: dispatch-time readers use this, never a captured snapshot. */
23
26
  export declare function getAccountSet(): AccountSet;
24
27
  /** Re-resolve after a config.json mutation and swap the live set. */
package/dist/accounts.js CHANGED
@@ -15,14 +15,14 @@ export function getTokenDir() {
15
15
  function parseCsv(value) {
16
16
  return (value ?? '').split(',').map((s) => s.trim()).filter(Boolean);
17
17
  }
18
- function accountPaths(alias) {
18
+ function accountPaths(alias, dir = tokenDir) {
19
19
  return {
20
- tokenPath: path.join(tokenDir, alias, 'token.json'),
21
- encPath: path.join(tokenDir, `${alias}.enc`),
20
+ tokenPath: path.join(dir, alias, 'token.json'),
21
+ encPath: path.join(dir, `${alias}.enc`),
22
22
  };
23
23
  }
24
24
  /** v5 env parser, guards verbatim. Format: GOOGLE_ACCOUNTS="alias1:email1,alias2:email2". */
25
- function parseEnvAccounts(raw, adminAliases) {
25
+ function parseEnvAccounts(raw, adminAliases, dir = tokenDir) {
26
26
  const configs = {};
27
27
  const aliases = [];
28
28
  for (const entry of raw.split(',')) {
@@ -49,7 +49,7 @@ function parseEnvAccounts(raw, adminAliases) {
49
49
  aliases.push(alias);
50
50
  configs[alias] = {
51
51
  email,
52
- ...accountPaths(alias),
52
+ ...accountPaths(alias, dir),
53
53
  admin: adminAliases.includes(alias) || undefined,
54
54
  source: 'env',
55
55
  };
@@ -74,7 +74,8 @@ 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', opts = {}) {
78
+ const dir = opts.tokenDir ?? tokenDir;
78
79
  const adminEnv = parseCsv(env.GOOGLE_ADMIN_ACCOUNTS);
79
80
  const rawEnv = env.GOOGLE_ACCOUNTS;
80
81
  const fail = (slug, message) => {
@@ -102,7 +103,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
102
103
  }
103
104
  }
104
105
  if (rawEnv && rawEnv.trim() !== '') {
105
- const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv);
106
+ const { aliases, configs } = parseEnvAccounts(rawEnv, adminEnv, dir);
106
107
  materializeFirstRun(aliases, configs, filePath);
107
108
  const def = resolveDefaultAccount(env, null, aliases, fail);
108
109
  return {
@@ -167,7 +168,7 @@ export function resolveAccounts(env = process.env, filePath = configFilePath(),
167
168
  aliases.push(alias);
168
169
  configs[alias] = {
169
170
  email: entry.email,
170
- ...accountPaths(alias),
171
+ ...accountPaths(alias, dir),
171
172
  scopeProfile: entry.scopeProfile,
172
173
  admin: adminEnv.length > 0 ? adminEnv.includes(alias) : entry.admin,
173
174
  source: 'config',
@@ -196,7 +197,7 @@ function resolveDefaultAccount(env, fromConfig, aliases, fail) {
196
197
  }
197
198
  return { defaultAccount: value, defaultAccountSource: fromEnv ? 'env' : 'config' };
198
199
  }
199
- function fileStamp(filePath, version) {
200
+ export function fileStamp(filePath, version) {
200
201
  try {
201
202
  return `${version}:${fs.statSync(filePath).mtimeMs}`;
202
203
  }
@@ -1,6 +1,17 @@
1
1
  export declare const CONFIG_VERSION = 1;
2
2
  export declare function configDir(env?: NodeJS.ProcessEnv): string;
3
3
  export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
4
+ export declare function tenantsDir(env?: NodeJS.ProcessEnv): string;
5
+ export declare function tenantConfigFilePath(tenantId: string, env?: NodeJS.ProcessEnv): string;
6
+ export declare function tenantTokenDir(tenantId: string, env?: NodeJS.ProcessEnv): string;
7
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
8
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
9
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
10
+ * tenant provisions first) must never stay group-readable. */
11
+ export declare function ensureTenantDirs(tenantId: string, env?: NodeJS.ProcessEnv): {
12
+ configFile: string;
13
+ tokenDir: string;
14
+ };
4
15
  export declare const ALIAS_RE: RegExp;
5
16
  export declare const RESERVED_ALIASES: string[];
6
17
  export declare function isReservedAlias(name: string): boolean;
@@ -10,6 +10,38 @@ export function configDir(env = process.env) {
10
10
  export function configFilePath(env = process.env) {
11
11
  return path.join(configDir(env), 'config.json');
12
12
  }
13
+ export function tenantsDir(env = process.env) {
14
+ return path.join(configDir(env), 'tenants');
15
+ }
16
+ // A tenant id becomes a filesystem path segment exactly like an account
17
+ // alias, so it rides the same traversal guard.
18
+ function assertTenantId(tenantId) {
19
+ if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId)) {
20
+ throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
21
+ }
22
+ }
23
+ export function tenantConfigFilePath(tenantId, env = process.env) {
24
+ assertTenantId(tenantId);
25
+ return path.join(tenantsDir(env), tenantId, 'config.json');
26
+ }
27
+ export function tenantTokenDir(tenantId, env = process.env) {
28
+ assertTenantId(tenantId);
29
+ return path.join(tenantsDir(env), tenantId, 'tokens');
30
+ }
31
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
32
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
33
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
34
+ * tenant provisions first) must never stay group-readable. */
35
+ export function ensureTenantDirs(tenantId, env = process.env) {
36
+ const tokenDir = tenantTokenDir(tenantId, env);
37
+ const levels = [configDir(env), tenantsDir(env), path.join(tenantsDir(env), tenantId), tokenDir];
38
+ for (const dir of levels) {
39
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
40
+ if (process.platform !== 'win32')
41
+ fs.chmodSync(dir, 0o700);
42
+ }
43
+ return { configFile: tenantConfigFilePath(tenantId, env), tokenDir };
44
+ }
13
45
  // Alias charset is load-bearing security, not cosmetics: the alias becomes a
14
46
  // tokenDir path segment (encPath), so this guard blocks path traversal.
15
47
  export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
package/dist/fanout.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  export declare const CSV_RE: RegExp;
3
- export declare function fanoutAccountField(description: string): z.ZodType;
3
+ export declare function fanoutAccountField(description: string, aliases: readonly string[]): z.ZodType;
4
4
  export type AccountSelector = {
5
5
  ok: true;
6
6
  fanout: boolean;
package/dist/fanout.js CHANGED
@@ -1,19 +1,22 @@
1
1
  import { z } from 'zod';
2
- import { ACCOUNTS, getAccountSet, unknownAliasMessage } from './accounts.js';
2
+ import { getAccountSet, unknownAliasMessage } from './accounts.js';
3
3
  export const CSV_RE = /^[a-zA-Z0-9_-]+(\s*,\s*[a-zA-Z0-9_-]+)+$/;
4
4
  const FANOUT_CONCURRENCY = 5;
5
- export function fanoutAccountField(description) {
6
- const csvExample = ACCOUNTS.length > 1 ? `; or a CSV subset like "${ACCOUNTS.slice(0, 2).join(',')}"` : '';
5
+ // `aliases` is required (no global default): the field is baked into a
6
+ // registry's inputSchema, so it must be built from THAT registry's account
7
+ // set or a '*' would advertise and accept another registry's aliases.
8
+ export function fanoutAccountField(description, aliases) {
9
+ const csvExample = aliases.length > 1 ? `; or a CSV subset like "${aliases.slice(0, 2).join(',')}"` : '';
7
10
  // The union's OWN error only fires when every branch aborts at the type
8
11
  // check (a non-string). For a string, zod surfaces the single non-aborted
9
12
  // branch verbatim, which is the CSV regex, so a mistyped alias used to read
10
13
  // as "Invalid string: must match pattern /^[a-zA-Z0-9_-]+(,...)/" and never
11
14
  // named an alias. Hence the same message on BOTH.
12
- const bad = () => unknownAliasMessage(ACCOUNTS, true);
15
+ const bad = () => unknownAliasMessage(aliases, true);
13
16
  return z
14
- // '*' first so the tuple is statically non-empty even when ACCOUNTS is empty
17
+ // '*' first so the tuple is statically non-empty even when aliases is empty
15
18
  // (a fresh install): z.enum requires [string, ...string[]].
16
- .union([z.enum(['*', ...ACCOUNTS]), z.string().regex(CSV_RE, { error: bad })], { error: bad })
19
+ .union([z.enum(['*', ...aliases]), z.string().regex(CSV_RE, { error: bad })], { error: bad })
17
20
  .optional()
18
21
  .describe(`${description}; "*" = all accounts${csvExample}; omit for the default account`);
19
22
  }
@@ -6,9 +6,11 @@ import { type Policy, type Transport } from './write-control.js';
6
6
  * The one forward-compat seam (frozen public API): the free core builds
7
7
  * exactly one context with subject "owner"; EE later instantiates N contexts
8
8
  * from N registries. Nothing here may assume a global current-account.
9
+ * `subject` is a string (not the literal 'owner') so a context can carry a
10
+ * tenant id; the free core still always builds 'owner'.
9
11
  */
10
12
  export interface IdentityContext {
11
- subject: 'owner';
13
+ subject: string;
12
14
  accounts: AccountSet;
13
15
  policy: Policy;
14
16
  getClient: typeof getClient;
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from 'zod';
3
3
  import { type Policy } from './write-control.js';
4
+ import { type AccountSet } from './accounts.js';
4
5
  import type { ArgShape } from './arg-normalize.js';
5
6
  import type { Metrics } from './usage-metrics.js';
6
7
  export type Cud = 'read' | 'create' | 'update' | 'delete';
@@ -41,6 +42,7 @@ export declare function inferCud(name: string): Cud;
41
42
  export declare class ToolRegistry {
42
43
  private readonly server;
43
44
  private readonly metrics;
45
+ private readonly accounts;
44
46
  readonly tools: ToolEntry[];
45
47
  readonly policy: Policy;
46
48
  readonly registerTool: McpServer['registerTool'];
@@ -57,7 +59,7 @@ export declare class ToolRegistry {
57
59
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
58
60
  * lazy surface to curated without touching the configured mode. */
59
61
  private expanded;
60
- constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null);
62
+ constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode, metrics?: Metrics | null, accounts?: () => AccountSet);
61
63
  registerMeta: McpServer['registerTool'];
62
64
  services(): string[];
63
65
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
package/dist/registry.js CHANGED
@@ -91,6 +91,7 @@ export function inferCud(name) {
91
91
  export class ToolRegistry {
92
92
  server;
93
93
  metrics;
94
+ accounts;
94
95
  tools = [];
95
96
  policy;
96
97
  registerTool;
@@ -107,9 +108,14 @@ export class ToolRegistry {
107
108
  /** Agent-toggled runtime overlay (discover_all / discover_reset): lifts a
108
109
  * lazy surface to curated without touching the configured mode. */
109
110
  expanded = false;
110
- constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null) {
111
+ constructor(server, policy, mode = resolveDiscoveryMode(), metrics = null,
112
+ // The registry's OWN account view: fan-out expansion, selector validation
113
+ // and default-account injection all read through it so a registry built
114
+ // over a subset never observes (or leaks) the global set.
115
+ accounts = getAccountSet) {
111
116
  this.server = server;
112
117
  this.metrics = metrics;
118
+ this.accounts = accounts;
113
119
  this.policy = policy;
114
120
  this.mode = mode;
115
121
  this.registerTool = ((name, config, handler) => {
@@ -142,12 +148,12 @@ export class ToolRegistry {
142
148
  const hasAccountField = 'account' in inputShape && !DEFAULT_ACCOUNT_EXCLUDE.has(name);
143
149
  if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
144
150
  const description = inputShape.account.description ?? 'Google account alias';
145
- inputShape = { ...inputShape, account: fanoutAccountField(description) };
151
+ inputShape = { ...inputShape, account: fanoutAccountField(description, this.accounts().aliases) };
146
152
  baseHandler = async (...args) => {
147
153
  const first = args[0];
148
- const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '');
154
+ const parsed = parseAccountSelector(typeof first?.account === 'string' ? first.account : '', this.accounts().aliases);
149
155
  if (!parsed.ok)
150
- return invalidAccountsResult(parsed.invalid);
156
+ return invalidAccountsResult(parsed.invalid, this.accounts().aliases);
151
157
  if (!parsed.fanout)
152
158
  return handler({ ...first, account: parsed.aliases[0] }, ...args.slice(1));
153
159
  return runFanout(handler, args, parsed.aliases);
@@ -188,7 +194,7 @@ export class ToolRegistry {
188
194
  // heals for a client that always omits account (this branch never
189
195
  // reaches getClient's probe). One stat, only on omission.
190
196
  refreshAccountSetIfStale();
191
- const def = getAccountSet().defaultAccount;
197
+ const def = this.accounts().defaultAccount;
192
198
  if (!def) {
193
199
  return {
194
200
  content: [
@@ -197,7 +203,7 @@ export class ToolRegistry {
197
203
  text: JSON.stringify({
198
204
  error: 'E_NO_DEFAULT_ACCOUNT',
199
205
  message: 'No "account" given and no default account is configured.',
200
- hint: `Pass account explicitly (valid: ${getAccountSet().aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
206
+ hint: `Pass account explicitly (valid: ${this.accounts().aliases.join(', ')}), or set GOOGLE_DEFAULT_ACCOUNT / "defaultAccount" in config.json.`,
201
207
  retriable: false,
202
208
  }),
203
209
  },
@@ -1,7 +1,8 @@
1
1
  import type { TokenData } from './types.js';
2
2
  export declare function deriveKey(masterKey: string): Buffer;
3
- export declare function encryptToken(data: object, masterKey: string): string;
4
- export declare function decryptToken(fileContents: string, masterKey: string): TokenData;
3
+ export declare function deriveTenantKey(masterKey: string, tenantId: string): Buffer;
4
+ export declare function encryptToken(data: object, masterKey: string, tenantId?: string): string;
5
+ export declare function decryptToken(fileContents: string, masterKey: string, tenantId?: string): TokenData;
5
6
  export declare function readToken(alias: string): TokenData | null;
6
7
  export declare function writeToken(alias: string, data: object): void;
7
8
  export declare function updateToken(alias: string, updates: object): void;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.38",
3
+ "version": "6.0.0-alpha.39",
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",