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

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.
@@ -1,3 +1,5 @@
1
+ import { getClient } from './client.js';
2
+ import { readToken } from './token-store.js';
1
3
  import type { ApiProbeResult } from './doctor.js';
2
4
  export interface ApiProbeSpec {
3
5
  service: string;
@@ -15,4 +17,7 @@ export interface ApiProbeDeps {
15
17
  grantedScopes: (alias: string) => string[];
16
18
  request: (alias: string, url: string) => Promise<void>;
17
19
  }
20
+ /** Probe deps over one context's token reads and client; the module-level
21
+ * pair is the single owner's. */
22
+ export declare function apiProbeDepsFor(readTokenFn: typeof readToken, getClientFn: typeof getClient): ApiProbeDeps;
18
23
  export declare function probeApiEnablement(alias: string, deps?: ApiProbeDeps): Promise<ApiProbeResult[]>;
package/dist/api-probe.js CHANGED
@@ -24,21 +24,26 @@ export const API_PROBES = [
24
24
  export function planProbes(granted, probes = API_PROBES) {
25
25
  return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
26
26
  }
27
- const DEFAULT_DEPS = {
28
- grantedScopes: (alias) => {
29
- try {
30
- const scope = readToken(alias)?.scope;
31
- return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
32
- }
33
- catch {
34
- return [];
35
- }
36
- },
37
- request: async (alias, url) => {
38
- const auth = await getClient(alias);
39
- await auth.request({ url, timeout: 10_000 });
40
- },
41
- };
27
+ /** Probe deps over one context's token reads and client; the module-level
28
+ * pair is the single owner's. */
29
+ export function apiProbeDepsFor(readTokenFn, getClientFn) {
30
+ return {
31
+ grantedScopes: (alias) => {
32
+ try {
33
+ const scope = readTokenFn(alias)?.scope;
34
+ return typeof scope === 'string' ? scope.split(' ').filter(Boolean) : [];
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ },
40
+ request: async (alias, url) => {
41
+ const auth = await getClientFn(alias);
42
+ await auth.request({ url, timeout: 10_000 });
43
+ },
44
+ };
45
+ }
46
+ const DEFAULT_DEPS = apiProbeDepsFor(readToken, getClient);
42
47
  export async function probeApiEnablement(alias, deps = DEFAULT_DEPS) {
43
48
  const results = [];
44
49
  for (const spec of planProbes(deps.grantedScopes(alias))) {
package/dist/auth.d.ts CHANGED
@@ -14,5 +14,5 @@ export declare function getAdminAccounts(set?: AccountSet): string[];
14
14
  * legacy env) changes its consent set and requires re-running auth. Evaluated
15
15
  * per account: `work` can carry admin + gmail_settings while `personal` is
16
16
  * never asked for them. */
17
- export declare function resolveScopesForAccount(alias: string): string[];
17
+ export declare function resolveScopesForAccount(alias: string, set?: AccountSet): string[];
18
18
  export declare function runAuthFlow(args: string[]): Promise<void>;
package/dist/auth.js CHANGED
@@ -95,15 +95,15 @@ export function getAdminAccounts(set = getAccountSet()) {
95
95
  * legacy env) changes its consent set and requires re-running auth. Evaluated
96
96
  * per account: `work` can carry admin + gmail_settings while `personal` is
97
97
  * never asked for them. */
98
- export function resolveScopesForAccount(alias) {
99
- const profile = profileForAccount(alias);
98
+ export function resolveScopesForAccount(alias, set = getAccountSet()) {
99
+ const profile = profileForAccount(alias, set);
100
100
  const scopes = profile.includesBase === false ? [] : [...BASE_SCOPES];
101
101
  for (const bundle of profile.bundles) {
102
102
  if (bundle === 'admin')
103
103
  continue;
104
104
  scopes.push(...BUNDLE_CATALOG[bundle].scopes);
105
105
  }
106
- if (getAdminAccounts().includes(alias)) {
106
+ if (getAdminAccounts(set).includes(alias)) {
107
107
  scopes.push(...ADMIN_SCOPES);
108
108
  }
109
109
  return Array.from(new Set(scopes));
package/dist/client.d.ts CHANGED
@@ -21,9 +21,13 @@ export declare function makeGetClient(contextKey: string, deps: GetClientDeps):
21
21
  * getClient below (today's behavior); a tenant context passes its own. */
22
22
  export interface CuratedToolDeps {
23
23
  getClientFn?: typeof getClient;
24
+ /** Whether tools may read or write the server host's disk (local uploads,
25
+ * downloads, attachments). Absent = true; the host belongs to its operator,
26
+ * so a non-owner context passes false. */
27
+ localFiles?: boolean;
24
28
  }
25
29
  /** The single-owner client, bound to the global registry + token store. The
26
30
  * operator surfaces that never carry a tenant (api-probe, executor and
27
- * escape-hatch `deps.getClientFn ?? getClient` defaults, stdio dispatch)
28
- * resolve through this. */
31
+ * escape-hatch `deps.getClientFn ?? getClient` defaults) resolve through
32
+ * this. */
29
33
  export declare const getClient: (account: Account) => Promise<OAuth2Client>;
package/dist/client.js CHANGED
Binary file
package/dist/compose.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { ToolRegistry, type DiscoveryMode } from './registry.js';
3
- import type { IdentityContext } from './identity.js';
3
+ import { type IdentityContext } from './identity.js';
4
4
  import type { Metrics } from './usage-metrics.js';
5
5
  export declare function buildRegistry(server: McpServer, ctx: IdentityContext, mode?: DiscoveryMode, metrics?: Metrics | null): ToolRegistry;
package/dist/compose.js CHANGED
@@ -8,16 +8,30 @@ import { GENERATED_GATES, SERVICES } from './services.js';
8
8
  import { ToolRegistry } from './registry.js';
9
9
  import { registerDiscoverTools } from './discover.js';
10
10
  import { registerEscapeTools } from './tools/google-api.js';
11
- import { registerAccountTools } from './tools/accounts-tool.js';
11
+ import { accountHealthDepsFor, registerAccountTools } from './tools/accounts-tool.js';
12
12
  import { registerDiagnoseTool } from './doctor.js';
13
13
  import { registerAccountWizardTools } from './tools/account-wizard.js';
14
14
  import { getToolsets, toolsetEnabled } from './toolsets.js';
15
+ import { isOwnerContext } from './identity.js';
16
+ import { resolveScopesForAccount } from './auth.js';
15
17
  export function buildRegistry(server, ctx, mode, metrics = null) {
16
18
  const policy = ctx.policy;
17
19
  // The registry's account view IS the context's: fan-out expansion,
18
20
  // selector validation and default-account injection all follow this
19
21
  // context, not the process global (identical for the free core's one ctx).
20
22
  const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
23
+ // Every handler resolves its client, token reads and scope hints through
24
+ // this context; ctx.accounts is read per call, never snapshotted here, so
25
+ // the owner's live getter stays live.
26
+ const owner = isOwnerContext(ctx);
27
+ const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
28
+ const execDeps = {
29
+ getClientFn: ctx.getClient,
30
+ scopeDeps: {
31
+ readTokenFn: ctx.tokenStore.readToken,
32
+ profileFn: (alias) => resolveScopesForAccount(alias, ctx.accounts),
33
+ },
34
+ };
21
35
  const toolsets = getToolsets();
22
36
  if (toolsets !== 'all') {
23
37
  const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
@@ -37,7 +51,7 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
37
51
  }
38
52
  continue;
39
53
  }
40
- svc.register(registry);
54
+ svc.register(registry, clientDeps);
41
55
  }
42
56
  for (const gen of GENERATED_SERVICES) {
43
57
  if (!toolsetEnabled(toolsets, gen.name))
@@ -50,7 +64,7 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
50
64
  }
51
65
  continue;
52
66
  }
53
- gen.register(registry);
67
+ gen.register(registry, execDeps);
54
68
  }
55
69
  if (registry.services().length === 0) {
56
70
  const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
@@ -59,9 +73,12 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
59
73
  `Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
60
74
  }
61
75
  registerDiscoverTools(registry, policy);
62
- registerEscapeTools(registry, policy);
63
- registerAccountTools(registry);
76
+ registerEscapeTools(registry, policy, execDeps);
77
+ registerAccountTools(registry, accountHealthDepsFor(ctx.tokenStore));
64
78
  registerDiagnoseTool(registry, ctx);
65
- registerAccountWizardTools(registry, server);
79
+ // The wizard edits the owner's config.json and token store; any other
80
+ // context has neither.
81
+ if (owner)
82
+ registerAccountWizardTools(registry, server);
66
83
  return registry;
67
84
  }
@@ -14,9 +14,10 @@ export function tenantsDir(env = process.env) {
14
14
  return path.join(configDir(env), 'tenants');
15
15
  }
16
16
  // A tenant id becomes a filesystem path segment exactly like an account
17
- // alias, so it rides the same traversal guard.
17
+ // alias, so it rides the same traversal guard. 'owner' is the single-owner
18
+ // context's subject and client lane, so no tenant may take it.
18
19
  function assertTenantId(tenantId) {
19
- if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId)) {
20
+ if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
20
21
  throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
21
22
  }
22
23
  }
package/dist/doctor.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ToolRegistry } from './registry.js';
2
2
  import { getAccountSet } from './accounts.js';
3
3
  import { type AccountHealth } from './tools/accounts-tool.js';
4
+ import { type IdentityContext } from './identity.js';
4
5
  import { peekMasterKeyProvenance } from './master-key.js';
5
6
  import { type HttpConfig } from './http-config.js';
6
7
  export type Verdict = 'ok' | 'warn' | 'fail' | 'unknown';
@@ -68,6 +69,7 @@ export declare function renderDoctorText(report: DiagnosticsReport): string;
68
69
  export declare function renderReport(report: DiagnosticsReport, version: string): string;
69
70
  /** Exit non-zero when any section FAILs; with strict, WARN counts too. */
70
71
  export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
72
+ export type DiagnoseContext = Pick<IdentityContext, 'subject' | 'accounts' | 'getClient' | 'tokenStore'>;
71
73
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
72
74
  * engine. Registered as a META tool, like `account_list`: it introspects this
73
75
  * server rather than Google data, and as a normal tool it became a service of
@@ -75,9 +77,7 @@ export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean):
75
77
  * from already-registered services, and this runs after that), so it was
76
78
  * advertised only once something else expanded the surface. The README sends
77
79
  * people here when they are stuck, so it has to be findable. */
78
- export declare function registerDiagnoseTool(registry: ToolRegistry, ctx?: {
79
- subject: string;
80
- }): void;
80
+ export declare function registerDiagnoseTool(registry: ToolRegistry, ctx?: DiagnoseContext): void;
81
81
  export interface ResetOptions {
82
82
  account?: string;
83
83
  regenerateKey: boolean;
package/dist/doctor.js CHANGED
@@ -2,14 +2,15 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as readline from 'node:readline';
4
4
  import { getAccountSet } from './accounts.js';
5
- import { deriveAccountHealth } from './tools/accounts-tool.js';
5
+ import { accountHealthDepsFor, deriveAccountHealth } from './tools/accounts-tool.js';
6
+ import { isOwnerContext } from './identity.js';
6
7
  import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
7
8
  import { hasToken } from './token-store.js';
8
9
  import { configDir, loadConfigFile } from './config-file.js';
9
10
  import { envValueSource } from './env-load.js';
10
11
  import { reauthHint } from './reauth-hint.js';
11
12
  import { describeMetricsDir, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
12
- import { probeApiEnablement } from './api-probe.js';
13
+ import { apiProbeDepsFor, probeApiEnablement } from './api-probe.js';
13
14
  import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
14
15
  import { resolveHttpConfig, HttpConfigError } from './http-config.js';
15
16
  import { parseOwnerEmails } from './http-transport.js';
@@ -83,7 +84,7 @@ function sectionRuntime(deps) {
83
84
  }
84
85
  return { id: 1, title: 'Runtime', verdict: 'ok', lines: [`Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`] };
85
86
  }
86
- function sectionConfig(deps, set) {
87
+ function sectionConfig(deps, set, tenantScope = false) {
87
88
  const lines = [];
88
89
  let verdict = 'ok';
89
90
  let hint;
@@ -95,9 +96,14 @@ function sectionConfig(deps, set) {
95
96
  verdict: 'fail',
96
97
  slug: 'E_NO_ACCOUNTS_CONFIGURED',
97
98
  lines: ['No accounts configured.'],
98
- hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.',
99
+ ...(tenantScope ? {} : { hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.' }),
99
100
  };
100
101
  }
102
+ // The config file, process env, working directory and metrics store below
103
+ // are the operator's host, not the context's: a tenant sees its own count.
104
+ if (tenantScope) {
105
+ return { id: 2, title: 'Config', verdict: 'ok', lines: [`${set.aliases.length} account(s) configured`] };
106
+ }
101
107
  const cfgPath = path.join(configDir(), 'config.json');
102
108
  lines.push(deps.fileExists(cfgPath) ? `config.json present (${set.aliases.length} account(s))` : `config.json absent — accounts sourced from env (${set.aliases.length})`);
103
109
  const legacyEnv = LEGACY_ENV_KEYS.filter((k) => deps.env[k]);
@@ -303,12 +309,13 @@ export function overallVerdict(sections) {
303
309
  export async function runDiagnostics(deps = DEFAULT_DEPS, opts = {}) {
304
310
  // Tenant scope omits the OPERATOR sections: §3 (key provenance = box
305
311
  // infrastructure) and §7 (its owner-gate lines print MCP_OWNER_EMAILS
306
- // values — other people's addresses from a tenant's seat).
312
+ // values — other people's addresses from a tenant's seat), and trims §2 to
313
+ // the context's own account count.
307
314
  const tenantScope = opts.scope === 'tenant';
308
315
  const sections = [];
309
316
  sections.push(sectionRuntime(deps));
310
317
  const set = deps.accountSet();
311
- sections.push(sectionConfig(deps, set));
318
+ sections.push(sectionConfig(deps, set, tenantScope));
312
319
  const aliases = set?.aliases ?? [];
313
320
  if (!tenantScope)
314
321
  sections.push(sectionKeys(deps, aliases));
@@ -390,6 +397,26 @@ export function exitCodeFor(report, strict) {
390
397
  return 1;
391
398
  return 0;
392
399
  }
400
+ /** The engine's account, token and probe reads bound to one context, so a
401
+ * report never shows (or probes with) another context's accounts. */
402
+ function diagnosticsDepsFor(ctx) {
403
+ const health = accountHealthDepsFor(ctx.tokenStore, () => ctx.accounts);
404
+ const probe = apiProbeDepsFor(ctx.tokenStore.readToken, ctx.getClient);
405
+ return {
406
+ ...DEFAULT_DEPS,
407
+ accountSet: () => {
408
+ try {
409
+ return ctx.accounts;
410
+ }
411
+ catch {
412
+ return null;
413
+ }
414
+ },
415
+ accountHealth: (alias) => deriveAccountHealth(alias, health),
416
+ anyTokensExist: (aliases) => aliases.some((a) => ctx.tokenStore.hasToken(a)),
417
+ probeApi: (alias) => probeApiEnablement(alias, probe),
418
+ };
419
+ }
393
420
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
394
421
  * engine. Registered as a META tool, like `account_list`: it introspects this
395
422
  * server rather than Google data, and as a normal tool it became a service of
@@ -398,16 +425,17 @@ export function exitCodeFor(report, strict) {
398
425
  * advertised only once something else expanded the surface. The README sends
399
426
  * people here when they are stuck, so it has to be findable. */
400
427
  export function registerDiagnoseTool(registry, ctx) {
401
- // A non-owner context gets the tenant-scoped report; the free core's single
402
- // 'owner' context keeps today's full operator report.
403
- const scope = ctx && ctx.subject !== 'owner' ? 'tenant' : 'operator';
428
+ // A non-owner context gets the tenant-scoped report; only the owner context
429
+ // (or the context-free CLI surface) keeps the full operator report.
430
+ const scope = ctx && !isOwnerContext(ctx) ? 'tenant' : 'operator';
431
+ const deps = ctx ? diagnosticsDepsFor(ctx) : DEFAULT_DEPS;
404
432
  registry.registerMeta('diagnose', {
405
433
  annotations: { readOnlyHint: true, openWorldHint: true },
406
434
  description: 'Health report for this server: runtime, config, keys, per-account token status, scope grants, and API enablement. Read-only; returns copy-pasteable fixes for anything wrong. Call this to diagnose auth/config failures.',
407
435
  inputSchema: {},
408
436
  }, async () => {
409
437
  try {
410
- const result = await runDiagnostics(undefined, { scope });
438
+ const result = await runDiagnostics(deps, { scope });
411
439
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
412
440
  }
413
441
  catch (e) {
@@ -1,4 +1,5 @@
1
1
  import { getClient } from './client.js';
2
+ import { type ScopeSetDeps } from './scope-observability.js';
2
3
  export declare const MAX_RESPONSE_CHARS = 100000;
3
4
  export type QueryValue = string | number | boolean;
4
5
  export type QueryParams = Record<string, QueryValue | QueryValue[]>;
@@ -13,6 +14,8 @@ export interface ApiMethodRef {
13
14
  }
14
15
  export interface ExecuteDeps {
15
16
  getClientFn?: typeof getClient;
17
+ /** Token + profile view behind a 403 scope hint; absent = the single owner. */
18
+ scopeDeps?: ScopeSetDeps;
16
19
  }
17
20
  export interface ExecuteArgs {
18
21
  account: string;
package/dist/executor.js CHANGED
@@ -103,6 +103,6 @@ export async function executeApiMethod(method, args, deps = {}) {
103
103
  return { content: [{ type: 'text', text }] };
104
104
  }
105
105
  catch (error) {
106
- return handleGoogleApiError(error, args.account, undefined, () => method.scopes?.length ? scopeHintForMethod(method.scopes, args.account) : null);
106
+ return handleGoogleApiError(error, args.account, undefined, () => method.scopes?.length ? scopeHintForMethod(method.scopes, args.account, deps.scopeDeps) : null);
107
107
  }
108
108
  }
@@ -21,6 +21,7 @@ export interface IdentityContext {
21
21
  hasToken: typeof hasToken;
22
22
  };
23
23
  }
24
+ export declare function isOwnerContext(ctx: object | undefined): boolean;
24
25
  export declare function buildIdentityContext(env?: NodeJS.ProcessEnv, opts?: {
25
26
  transport?: Transport;
26
27
  }): IdentityContext;
package/dist/identity.js CHANGED
@@ -2,12 +2,20 @@ import { getAccountSet, getTokenDir, refreshAccountSetIfStale } from './accounts
2
2
  import { makeGetClient } from './client.js';
3
3
  import { createTokenStore } from './token-store.js';
4
4
  import { resolvePolicy } from './write-control.js';
5
+ // Only buildIdentityContext mints the owner brand. The subject string alone
6
+ // never unlocks the owner-only surfaces (account wizard, host-file tools, the
7
+ // operator diagnose report): a hand-built context claiming 'owner' stays a
8
+ // non-owner context.
9
+ const ownerContexts = new WeakSet();
10
+ export function isOwnerContext(ctx) {
11
+ return ctx !== undefined && ownerContexts.has(ctx);
12
+ }
5
13
  export function buildIdentityContext(env = process.env, opts = {}) {
6
14
  // Real closures over the single-owner state (the SAME factories EE
7
15
  // instantiates per tenant), bound here to the global registry + token dir —
8
16
  // behavior-identical to the module-level functions for the free core.
9
17
  const tokenStore = createTokenStore(getTokenDir());
10
- return {
18
+ const ctx = {
11
19
  subject: 'owner',
12
20
  // Live getter: the seam must always see the current registry, never a
13
21
  // snapshot pinned from before a wizard mutation or cross-process reload.
@@ -25,4 +33,6 @@ export function buildIdentityContext(env = process.env, opts = {}) {
25
33
  }),
26
34
  tokenStore,
27
35
  };
36
+ ownerContexts.add(ctx);
37
+ return ctx;
28
38
  }
@@ -48,12 +48,14 @@ function anyEncFileUnder(dir) {
48
48
  }
49
49
  // Glob the token dir instead of iterating registered aliases: an orphan .enc
50
50
  // (alias removed from the registry, or a partial GOOGLE_ACCOUNTS override in
51
- // one client) must still stop a fresh key from being minted. The walk is
52
- // recursive and also covers configDir()/tenants (the tenant-namespaced token
53
- // root): a flat scan returns false the moment tokens nest, and minting a
54
- // fresh MASTER_KEY over existing .enc files bricks them irreversibly.
51
+ // one client) must still stop a fresh key from being minted. TOKEN_STORE_PATH
52
+ // may relocate the token dir outside configDir(), so scan it directly; the
53
+ // recursive configDir() walk then covers BOTH the tenant-namespaced token root
54
+ // (tenants/<id>) AND any top-level master-key-encrypted registry beside it
55
+ // (e.g. a downstream tenant map / invite store). Minting a fresh MASTER_KEY
56
+ // over any existing .enc bricks it irreversibly, so the scan must see them all.
55
57
  function anyTokenFileExists() {
56
- return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(path.join(configDir(), 'tenants'));
58
+ return anyEncFileUnder(getTokenDir()) || anyEncFileUnder(configDir());
57
59
  }
58
60
  function readKeyFile(filePath) {
59
61
  try {
@@ -65,6 +65,9 @@ export declare class ToolRegistry {
65
65
  /** The registry's OWN alias list: registration-time schema builders (the
66
66
  * generated account enums) read this, never the process global. */
67
67
  accountAliases(): readonly string[];
68
+ /** The registry's OWN account set: handlers needing an account's email or
69
+ * profile read this, never the process global. */
70
+ accountSet(): AccountSet;
68
71
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
69
72
  * normalization; the kind drives value coercion on renamed keys). */
70
73
  argShape(name: string): ArgShape | undefined;
package/dist/registry.js CHANGED
@@ -225,7 +225,7 @@ export class ToolRegistry {
225
225
  const v = a?.account;
226
226
  if (typeof v !== 'string' || (v !== '*' && !v.includes(',')))
227
227
  return 1;
228
- const sel = parseAccountSelector(v);
228
+ const sel = parseAccountSelector(v, this.accounts().aliases);
229
229
  return sel.ok ? sel.aliases.length : 1;
230
230
  })
231
231
  : finalHandler;
@@ -250,6 +250,11 @@ export class ToolRegistry {
250
250
  accountAliases() {
251
251
  return this.accounts().aliases;
252
252
  }
253
+ /** The registry's OWN account set: handlers needing an account's email or
254
+ * profile read this, never the process global. */
255
+ accountSet() {
256
+ return this.accounts();
257
+ }
253
258
  /** Declared input-schema keys + scalar kinds for one tool (tools/call arg
254
259
  * normalization; the kind drives value coercion on renamed keys). */
255
260
  argShape(name) {
@@ -1,4 +1,3 @@
1
- import { resolveScopesForAccount } from './auth.js';
2
1
  import { readToken } from './token-store.js';
3
2
  export type ScopeState = 'callable' | 'requestable_not_granted' | 'not_requestable';
4
3
  export type NotRequestableReason = 'add_bundle' | 'account_type' | 'unknown_scope';
@@ -35,12 +34,15 @@ export interface AccountScopeSets {
35
34
  granted: Set<string>;
36
35
  profile: Set<string>;
37
36
  }
37
+ /** Where a scope hint reads the granted token and the configured profile;
38
+ * absent = the single-owner token store and global registry. */
39
+ export interface ScopeSetDeps {
40
+ readTokenFn?: typeof readToken;
41
+ profileFn?: (alias: string) => string[];
42
+ }
38
43
  /** Best-effort per-account sets; null when the token/profile is unreadable
39
44
  * (decrypt error, missing token) — callers fall back to generic hints. */
40
- export declare function accountScopeSets(alias: string, deps?: {
41
- readTokenFn?: typeof readToken;
42
- profileFn?: typeof resolveScopesForAccount;
43
- }): AccountScopeSets | null;
45
+ export declare function accountScopeSets(alias: string, deps?: ScopeSetDeps): AccountScopeSets | null;
44
46
  /**
45
47
  * Runtime enrichment for a scope 403 on a method whose required scopes are
46
48
  * known (escape hatch: runtime Discovery; generated tools: baked). The
@@ -48,7 +50,7 @@ export declare function accountScopeSets(alias: string, deps?: {
48
50
  * profile means Google refuses it for this account type (personal Gmail),
49
51
  * so re-auth would loop — report the dead end instead.
50
52
  */
51
- export declare function scopeHintForMethod(required: readonly string[], alias: string, deps?: Parameters<typeof accountScopeSets>[1]): {
53
+ export declare function scopeHintForMethod(required: readonly string[], alias: string, deps?: ScopeSetDeps): {
52
54
  hint: string;
53
55
  retriable: boolean;
54
56
  } | null;
@@ -1,3 +1,12 @@
1
1
  import * as fs from 'fs';
2
+ /** Refusal for a host-file argument on a context that may not touch the
3
+ * server's disk: the host belongs to its operator, not to every caller. */
4
+ export declare function hostFilesRefused(account: string | undefined, what: string): {
5
+ content: {
6
+ type: "text";
7
+ text: string;
8
+ }[];
9
+ isError: true;
10
+ };
2
11
  export declare function prepareLocalDest(savePath: string, filename: string): string;
3
12
  export declare function openLocalReadStream(localPath: string): Promise<fs.ReadStream>;
@@ -1,5 +1,23 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { stringifyEnvelope } from './_errors.js';
4
+ /** Refusal for a host-file argument on a context that may not touch the
5
+ * server's disk: the host belongs to its operator, not to every caller. */
6
+ export function hostFilesRefused(account, what) {
7
+ return {
8
+ content: [{
9
+ type: 'text',
10
+ text: stringifyEnvelope({
11
+ error: 'forbidden',
12
+ message: `${what}: this caller may not read files on the machine running the server.`,
13
+ hint: 'Put the file in Drive first and work from its Drive id.',
14
+ retriable: false,
15
+ account,
16
+ }),
17
+ }],
18
+ isError: true,
19
+ };
20
+ }
3
21
  // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
4
22
  export function prepareLocalDest(savePath, filename) {
5
23
  const name = path.basename(filename);
@@ -1,5 +1,7 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
+ import { type AccountSet } from '../accounts.js';
2
3
  import { type ScopesReport } from '../scope-observability.js';
4
+ import { type TokenStore } from '../token-store.js';
3
5
  export interface AccountHealthDeps {
4
6
  hasToken: (alias: string) => boolean;
5
7
  readToken: (alias: string) => {
@@ -13,7 +15,12 @@ export interface AccountHealthDeps {
13
15
  * "registered but not requestable" view (B9). account_list stays compact:
14
16
  * its report universe is profile ∪ granted only. */
15
17
  registeredScopes?: () => string[];
18
+ /** Account view health is derived against; absent = the global registry. */
19
+ accounts?: () => AccountSet;
16
20
  }
21
+ /** Health deps over one context's token store (and, optionally, its account
22
+ * view); the module-level functions are the single owner's store. */
23
+ export declare function accountHealthDepsFor(store: Pick<TokenStore, 'hasToken' | 'readToken'>, accounts?: () => AccountSet): AccountHealthDeps;
17
24
  export type TokenStatus = 'ok' | 'expired_refreshable' | 'needs_reauth' | 'missing' | 'decrypt_error';
18
25
  export interface AccountHealth {
19
26
  alias: string;
@@ -4,16 +4,23 @@ import { getAccountSet, refreshAccountSetIfStale } from '../accounts.js';
4
4
  import { buildScopesReport } from '../scope-observability.js';
5
5
  import { getAdminAccounts, resolveScopesForAccount } from '../auth.js';
6
6
  import { hasToken, readToken } from '../token-store.js';
7
- const DEFAULT_DEPS = {
8
- hasToken,
9
- readToken,
10
- fileExists: fs.existsSync,
11
- now: Date.now,
12
- };
7
+ /** Health deps over one context's token store (and, optionally, its account
8
+ * view); the module-level functions are the single owner's store. */
9
+ export function accountHealthDepsFor(store, accounts) {
10
+ return {
11
+ hasToken: store.hasToken,
12
+ readToken: store.readToken,
13
+ fileExists: fs.existsSync,
14
+ now: Date.now,
15
+ ...(accounts ? { accounts } : {}),
16
+ };
17
+ }
18
+ const DEFAULT_DEPS = accountHealthDepsFor({ hasToken, readToken });
13
19
  export function deriveAccountHealth(alias, deps = DEFAULT_DEPS) {
14
- const config = getAccountSet().configs[alias];
15
- const admin = getAdminAccounts().includes(alias);
16
- const configured = resolveScopesForAccount(alias);
20
+ const set = (deps.accounts ?? getAccountSet)();
21
+ const config = set.configs[alias];
22
+ const admin = getAdminAccounts(set).includes(alias);
23
+ const configured = resolveScopesForAccount(alias, set);
17
24
  // BR-10: env-sourced accounts are not persisted in config.json, so the
18
25
  // account wizard cannot edit them — the deployer edits env instead.
19
26
  const base = {
@@ -88,14 +95,16 @@ export function registerAccountTools(registry, deps = DEFAULT_DEPS) {
88
95
  _meta: { 'anthropic/alwaysLoad': true },
89
96
  }, async () => {
90
97
  refreshAccountSetIfStale();
98
+ const set = registry.accountSet();
99
+ const health = { ...deps, accounts: () => set };
91
100
  return {
92
101
  content: [
93
102
  {
94
103
  type: 'text',
95
104
  text: JSON.stringify({
96
- defaultAccount: getAccountSet().defaultAccount ?? null,
97
- defaultAccountSource: getAccountSet().defaultAccountSource ?? null,
98
- accounts: getAccountSet().aliases.map((alias) => deriveAccountHealth(alias, deps)),
105
+ defaultAccount: set.defaultAccount ?? null,
106
+ defaultAccountSource: set.defaultAccountSource ?? null,
107
+ accounts: set.aliases.map((alias) => deriveAccountHealth(alias, health)),
99
108
  }),
100
109
  },
101
110
  ],
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import { coerceArray, coerceBoolean, coerceNumber } from './_coerce.js';
3
3
  import { drive as driveClient } from '@googleapis/drive';
4
- import { accountArgLive, getAccountSet } from '../accounts.js';
4
+ import { accountArgLive } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams, safeMessage, stringifyEnvelope } from './_errors.js';
7
- import { openLocalReadStream, prepareLocalDest } from './_local-files.js';
7
+ import { hostFilesRefused, openLocalReadStream, prepareLocalDest } from './_local-files.js';
8
8
  import { checkOutbound, outboundDeniedEnvelope, resolveOutboundAllowlist } from '../outbound-allowlist.js';
9
9
  import { isAllowed, writeDisabledResult } from '../write-control.js';
10
10
  import { capText, listResult } from '../trim.js';
@@ -128,6 +128,8 @@ export function registerDriveTools(server, deps = {}) {
128
128
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
129
129
  const requiredAccountEnum = accountArgLive(() => server.accountAliases());
130
130
  const getClientFn = deps.getClientFn ?? getClient;
131
+ const localFiles = deps.localFiles ?? true;
132
+ const registerHostFileTool = localFiles ? server.registerTool : (() => undefined);
131
133
  // ─── Read / search / list ──────────────────────────────────────────────
132
134
  server.registerTool('drive_search', {
133
135
  description: 'Search files in a Google Drive account. A plain keyword is treated as a full-text search; pass Drive query syntax for a structured search.',
@@ -343,7 +345,7 @@ export function registerDriveTools(server, deps = {}) {
343
345
  }
344
346
  });
345
347
  // ─── Write / upload / download ─────────────────────────────────────────
346
- server.registerTool('drive_upload', {
348
+ registerHostFileTool('drive_upload', {
347
349
  description: 'Upload a local file to Google Drive. Pass `convertTo` to import it as a native, editable Google Doc/Sheet/Slides/Drawing instead of storing the raw bytes.',
348
350
  inputSchema: {
349
351
  account: accountEnum.describe('Google account alias'),
@@ -381,7 +383,7 @@ export function registerDriveTools(server, deps = {}) {
381
383
  return handleDriveError(error, account);
382
384
  }
383
385
  });
384
- server.registerTool('drive_download', {
386
+ registerHostFileTool('drive_download', {
385
387
  description: 'Download a binary file from Drive to local disk. For Google Workspace formats (Docs, Sheets, Slides), use drive_export instead.',
386
388
  inputSchema: {
387
389
  account: accountEnum.describe('Google account alias'),
@@ -409,7 +411,7 @@ export function registerDriveTools(server, deps = {}) {
409
411
  return handleDriveError(error, account);
410
412
  }
411
413
  });
412
- server.registerTool('drive_export', {
414
+ registerHostFileTool('drive_export', {
413
415
  description: 'Export a Google Workspace document (Doc, Sheet, Slide) to a standard format and save to disk. Supported: PDF, DOCX, XLSX, PPTX, TXT, CSV, Markdown (text/markdown for Docs).',
414
416
  inputSchema: {
415
417
  account: accountEnum.describe('Google account alias'),
@@ -476,11 +478,20 @@ export function registerDriveTools(server, deps = {}) {
476
478
  fileId: z.string().min(1).describe('Google Drive file ID'),
477
479
  newName: z.string().optional().describe('New filename'),
478
480
  newParentFolderId: z.string().optional().describe('Move to this folder, named newParentFolderId here, not parentFolderId'),
479
- localPath: z.string().optional().describe('Replace file content with this local file (path on the machine running the server)'),
480
- mimeType: z.string().optional().describe('MIME type of the replacement file (required if localPath is provided)'),
481
- convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted).'),
481
+ ...(localFiles
482
+ ? {
483
+ localPath: z.string().optional().describe('Replace file content with this local file (path on the machine running the server)'),
484
+ mimeType: z.string().optional().describe('MIME type of the replacement file (required if localPath is provided)'),
485
+ convertTo: z.enum(CONVERT_TO_VALUES).optional().describe('When replacing content via localPath, convert the new content into this native Google Workspace type on import: "document" | "spreadsheet" | "presentation" | "drawing" (full application/vnd.google-apps.* ids also accepted).'),
486
+ }
487
+ : {}),
482
488
  },
483
- }, async ({ account, fileId, newName, newParentFolderId, localPath: localPathArg, mimeType: mimeTypeArg, convertTo }) => {
489
+ }, async (args) => {
490
+ const { account, fileId, newName, newParentFolderId } = args;
491
+ // Declared only when the context may read host files (see the shape).
492
+ const { localPath: localPathArg, mimeType: mimeTypeArg, convertTo } = args;
493
+ if (localPathArg && !localFiles)
494
+ return hostFilesRefused(account, 'localPath');
484
495
  try {
485
496
  const auth = await getClientFn(account);
486
497
  const drive = driveClient({ version: 'v3', auth });
@@ -1309,7 +1320,7 @@ export function registerDriveTools(server, deps = {}) {
1309
1320
  };
1310
1321
  }
1311
1322
  const intendedName = newName ?? meta.data.name ?? 'transferred-file';
1312
- const targetEmail = getAccountSet().configs[toAccount].email;
1323
+ const targetEmail = server.accountSet().configs[toAccount].email;
1313
1324
  const finish = async (data, strategy, flags) => {
1314
1325
  let moveFailed = false;
1315
1326
  let moveSkipped = false;
@@ -1,5 +1,6 @@
1
1
  import type { ToolRegistry } from '../../registry.js';
2
+ import type { ExecuteDeps } from './_shared.js';
2
3
  export declare const GENERATED_SERVICES: Array<{
3
4
  name: string;
4
- register: (registry: ToolRegistry) => void;
5
+ register: (registry: ToolRegistry, deps?: ExecuteDeps) => void;
5
6
  }>;
@@ -5,7 +5,7 @@ import { accountArgLive } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError, invalidParams, mapGoogleError } from './_errors.js';
7
7
  import { buildReplyHeaders, composeRaw, renderMarkdown, htmlToMarkdown, HeaderInjectionError } from './gmail-mime.js';
8
- import { prepareLocalDest } from './_local-files.js';
8
+ import { hostFilesRefused, prepareLocalDest } from './_local-files.js';
9
9
  import { checkOutbound } from '../outbound-allowlist.js';
10
10
  import addressparser from 'nodemailer/lib/addressparser/index.js';
11
11
  import { lookup as lookupMime } from 'mime-types';
@@ -106,9 +106,11 @@ export function deriveReplySubject(sourceSubject) {
106
106
  // The account's own-address set (primary + Gmail send-as aliases) used to
107
107
  // exclude the caller from reply-all. Send-as rarely changes, so memoize per
108
108
  // process; a stale miss only costs one extra self-copy, never a wrong send.
109
+ // Keyed by alias AND mailbox: two contexts can share an alias name.
109
110
  const ownAddressCache = new Map();
110
111
  function getOwnAddresses(gmail, account, primaryEmail) {
111
- let cached = ownAddressCache.get(account);
112
+ const key = `${account} ${normAddr(primaryEmail)}`;
113
+ let cached = ownAddressCache.get(key);
112
114
  if (!cached) {
113
115
  cached = (async () => {
114
116
  const set = new Set([normAddr(primaryEmail)]);
@@ -124,7 +126,7 @@ function getOwnAddresses(gmail, account, primaryEmail) {
124
126
  }
125
127
  return set;
126
128
  })();
127
- ownAddressCache.set(account, cached);
129
+ ownAddressCache.set(key, cached);
128
130
  }
129
131
  return cached;
130
132
  }
@@ -441,6 +443,8 @@ export function registerGmailTools(server, deps = {}) {
441
443
  // custody path is the context's, not the process global.
442
444
  const accountEnum = accountArgLive(() => server.accountAliases()).optional();
443
445
  const getClientFn = deps.getClientFn ?? getClient;
446
+ const localFiles = deps.localFiles ?? true;
447
+ const registerHostFileTool = localFiles ? server.registerTool : (() => undefined);
444
448
  server.registerTool('gmail_search', {
445
449
  description: 'Search messages in a Gmail account. Returns compact rows (id, from, subject, date, snippet); ' +
446
450
  'pass full=true for threadId, to, labelIds and the untruncated snippet.',
@@ -626,13 +630,15 @@ export function registerGmailTools(server, deps = {}) {
626
630
  .describe('With replyToMessageId: include the source To+Cc (minus your own addresses) in cc. Default false (reply to sender only).'),
627
631
  replyToThreadId: z.string().optional()
628
632
  .describe('Thread ID to send the message in'),
629
- attachments: coerceJson(attachmentSchema),
633
+ ...(localFiles ? { attachments: coerceJson(attachmentSchema) } : {}),
630
634
  },
631
635
  }, async ({ account, to, subject, body, htmlBody, allowRawHtml, cc, replyToMessageId, replyAll, replyToThreadId, attachments }) => {
636
+ if (!localFiles && attachments?.length)
637
+ return hostFilesRefused(account, 'attachments');
632
638
  try {
633
639
  const auth = await getClientFn(account);
634
640
  const gmail = gmailClient({ version: 'v1', auth });
635
- const config = (await import('../accounts.js')).getAccountSet().configs[account];
641
+ const config = server.accountSet().configs[account];
636
642
  if (htmlBody !== undefined) {
637
643
  throw new GmailComposeError('E_HTMLBODY_REMOVED', 'htmlBody was removed in v6: author Markdown in `body`; for literal HTML pass `allowRawHtml: true`.');
638
644
  }
@@ -690,7 +696,7 @@ export function registerGmailTools(server, deps = {}) {
690
696
  return mapped ?? handleGmailError(error, account);
691
697
  }
692
698
  });
693
- server.registerTool('gmail_download_attachment', {
699
+ registerHostFileTool('gmail_download_attachment', {
694
700
  description: 'Download an email attachment to local disk. Use gmail_read first to get the attachmentId.',
695
701
  inputSchema: {
696
702
  account: accountEnum.describe('Google account alias'),
@@ -741,13 +747,15 @@ export function registerGmailTools(server, deps = {}) {
741
747
  .describe('With replyToMessageId: include the source To+Cc (minus your own addresses) in cc. Default false (reply to sender only).'),
742
748
  replyToThreadId: z.string().optional()
743
749
  .describe('Thread ID to associate the draft with'),
744
- attachments: coerceJson(attachmentSchema),
750
+ ...(localFiles ? { attachments: coerceJson(attachmentSchema) } : {}),
745
751
  },
746
752
  }, async ({ account, to, subject, body, htmlBody, allowRawHtml, cc, replyToMessageId, replyAll, replyToThreadId, attachments }) => {
753
+ if (!localFiles && attachments?.length)
754
+ return hostFilesRefused(account, 'attachments');
747
755
  try {
748
756
  const auth = await getClientFn(account);
749
757
  const gmail = gmailClient({ version: 'v1', auth });
750
- const config = (await import('../accounts.js')).getAccountSet().configs[account];
758
+ const config = server.accountSet().configs[account];
751
759
  if (htmlBody !== undefined) {
752
760
  throw new GmailComposeError('E_HTMLBODY_REMOVED', 'htmlBody was removed in v6: author Markdown in `body`; for literal HTML pass `allowRawHtml: true`.');
753
761
  }
@@ -1,10 +1,9 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
2
  import { type Policy } from '../write-control.js';
3
- import { getClient } from '../client.js';
4
3
  import { type Toolsets } from '../toolsets.js';
4
+ import { type ExecuteDeps } from '../executor.js';
5
5
  import { type DiscoveryDeps, type DiscoveryMethod } from '../discovery-client.js';
6
- export interface EscapeDeps extends DiscoveryDeps {
7
- getClientFn?: typeof getClient;
6
+ export interface EscapeDeps extends DiscoveryDeps, ExecuteDeps {
8
7
  toolsets?: Toolsets;
9
8
  }
10
9
  /** Up to three closest known ids for the unknown_method did-you-mean hint;
@@ -204,6 +204,6 @@ export function registerEscapeTools(registry, policy, deps = {}) {
204
204
  pathParams: pathParams,
205
205
  queryParams: queryParams,
206
206
  body,
207
- }, { getClientFn });
207
+ }, { getClientFn, scopeDeps: deps.scopeDeps });
208
208
  });
209
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.52",
3
+ "version": "6.0.0-alpha.54",
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",
@@ -27,6 +27,7 @@
27
27
  "./boot-gates": "./dist/boot-gates.js",
28
28
  "./tenant-purge": "./dist/tenant-purge.js",
29
29
  "./doctor": "./dist/doctor.js",
30
+ "./write-control": "./dist/write-control.js",
30
31
  "./package.json": "./package.json"
31
32
  },
32
33
  "files": [