mcp-google-multi 6.0.0-alpha.53 → 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 {
@@ -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,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
- import type { IdentityContext } from './identity.js';
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,20 +4,36 @@
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';
11
- import { registerAccountTools } from './tools/accounts-tool.js';
13
+ import { accountHealthDepsFor, registerAccountTools } from './tools/accounts-tool.js';
12
14
  import { registerDiagnoseTool } from './doctor.js';
13
15
  import { registerAccountWizardTools } from './tools/account-wizard.js';
14
16
  import { getToolsets, toolsetEnabled } from './toolsets.js';
17
+ import { isOwnerContext } from './identity.js';
18
+ import { resolveScopesForAccount } from './auth.js';
15
19
  export function buildRegistry(server, ctx, mode, metrics = null) {
16
20
  const policy = ctx.policy;
17
21
  // The registry's account view IS the context's: fan-out expansion,
18
22
  // selector validation and default-account injection all follow this
19
23
  // context, not the process global (identical for the free core's one ctx).
20
24
  const registry = new ToolRegistry(server, policy, mode, metrics, () => ctx.accounts);
25
+ // Every handler resolves its client, token reads and scope hints through
26
+ // this context; ctx.accounts is read per call, never snapshotted here, so
27
+ // the owner's live getter stays live.
28
+ const owner = isOwnerContext(ctx);
29
+ const clientDeps = { getClientFn: ctx.getClient, localFiles: owner };
30
+ const execDeps = {
31
+ getClientFn: ctx.getClient,
32
+ scopeDeps: {
33
+ readTokenFn: ctx.tokenStore.readToken,
34
+ profileFn: (alias) => resolveScopesForAccount(alias, ctx.accounts),
35
+ },
36
+ };
21
37
  const toolsets = getToolsets();
22
38
  if (toolsets !== 'all') {
23
39
  const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
@@ -37,7 +53,7 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
37
53
  }
38
54
  continue;
39
55
  }
40
- svc.register(registry);
56
+ svc.register(registry, clientDeps);
41
57
  }
42
58
  for (const gen of GENERATED_SERVICES) {
43
59
  if (!toolsetEnabled(toolsets, gen.name))
@@ -50,7 +66,7 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
50
66
  }
51
67
  continue;
52
68
  }
53
- gen.register(registry);
69
+ gen.register(registry, execDeps);
54
70
  }
55
71
  if (registry.services().length === 0) {
56
72
  const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
@@ -59,9 +75,35 @@ export function buildRegistry(server, ctx, mode, metrics = null) {
59
75
  `Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
60
76
  }
61
77
  registerDiscoverTools(registry, policy);
62
- registerEscapeTools(registry, policy);
63
- registerAccountTools(registry);
78
+ registerEscapeTools(registry, policy, execDeps);
79
+ registerAccountTools(registry, accountHealthDepsFor(ctx.tokenStore));
64
80
  registerDiagnoseTool(registry, ctx);
65
- registerAccountWizardTools(registry, server);
81
+ // The wizard edits the owner's config.json and token store; any other
82
+ // context has neither.
83
+ if (owner)
84
+ registerAccountWizardTools(registry, server);
66
85
  return registry;
67
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
+ }
@@ -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
  }
@@ -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
@@ -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
  }
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`);
@@ -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.53",
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",
@@ -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": [