mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3

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.
Files changed (165) hide show
  1. package/dist/accounts.d.ts +17 -1
  2. package/dist/accounts.js +52 -15
  3. package/dist/api-probe.d.ts +5 -0
  4. package/dist/api-probe.js +20 -15
  5. package/dist/arg-normalize.js +6 -5
  6. package/dist/arg-strict.d.ts +7 -0
  7. package/dist/arg-strict.js +34 -4
  8. package/dist/auth.d.ts +4 -3
  9. package/dist/auth.js +11 -13
  10. package/dist/boot-gates.d.ts +26 -0
  11. package/dist/boot-gates.js +38 -0
  12. package/dist/cimd-cache.d.ts +38 -0
  13. package/dist/cimd-cache.js +117 -0
  14. package/dist/client.d.ts +32 -2
  15. package/dist/client.js +0 -0
  16. package/dist/compose.d.ts +11 -0
  17. package/dist/compose.js +109 -0
  18. package/dist/config-file.d.ts +13 -0
  19. package/dist/config-file.js +37 -1
  20. package/dist/discover.js +3 -1
  21. package/dist/discovery-client.d.ts +3 -0
  22. package/dist/discovery-client.js +25 -2
  23. package/dist/doctor.d.ts +6 -2
  24. package/dist/doctor.js +119 -23
  25. package/dist/executor.d.ts +3 -0
  26. package/dist/executor.js +4 -2
  27. package/dist/fanout.d.ts +9 -1
  28. package/dist/fanout.js +44 -10
  29. package/dist/fs-atomic.d.ts +11 -0
  30. package/dist/fs-atomic.js +219 -28
  31. package/dist/hint-provider.d.ts +129 -0
  32. package/dist/hint-provider.js +64 -0
  33. package/dist/http-transport.d.ts +62 -4
  34. package/dist/http-transport.js +343 -24
  35. package/dist/identity.d.ts +4 -1
  36. package/dist/identity.js +25 -6
  37. package/dist/index.js +66 -138
  38. package/dist/master-key.js +13 -5
  39. package/dist/mcp-token.d.ts +136 -19
  40. package/dist/mcp-token.js +569 -72
  41. package/dist/migrate-config.js +2 -2
  42. package/dist/oauth-as.d.ts +103 -3
  43. package/dist/oauth-as.js +374 -79
  44. package/dist/oauth-consent.d.ts +10 -0
  45. package/dist/oauth-consent.js +16 -0
  46. package/dist/outbound-allowlist.js +5 -2
  47. package/dist/reauth-hint.d.ts +1 -1
  48. package/dist/reauth-hint.js +12 -8
  49. package/dist/registry-transfer.d.ts +5 -0
  50. package/dist/registry-transfer.js +65 -5
  51. package/dist/registry.d.ts +18 -1
  52. package/dist/registry.js +71 -15
  53. package/dist/scope-catalog.d.ts +11 -0
  54. package/dist/scope-catalog.js +23 -6
  55. package/dist/scope-observability.d.ts +8 -6
  56. package/dist/scope-observability.js +29 -13
  57. package/dist/services.d.ts +15 -10
  58. package/dist/services.js +59 -15
  59. package/dist/ssrf-guard.d.ts +23 -9
  60. package/dist/ssrf-guard.js +101 -62
  61. package/dist/tenant-purge.d.ts +26 -0
  62. package/dist/tenant-purge.js +67 -0
  63. package/dist/token-store.d.ts +20 -2
  64. package/dist/token-store.js +0 -0
  65. package/dist/tools/_errors.d.ts +3 -0
  66. package/dist/tools/_errors.js +53 -24
  67. package/dist/tools/_local-files.d.ts +9 -0
  68. package/dist/tools/_local-files.js +18 -0
  69. package/dist/tools/account-wizard.d.ts +10 -1
  70. package/dist/tools/account-wizard.js +74 -17
  71. package/dist/tools/accounts-tool.d.ts +7 -0
  72. package/dist/tools/accounts-tool.js +34 -18
  73. package/dist/tools/admin.d.ts +10 -1
  74. package/dist/tools/admin.js +15 -11
  75. package/dist/tools/analytics.d.ts +10 -1
  76. package/dist/tools/analytics.js +13 -8
  77. package/dist/tools/calendar.d.ts +2 -1
  78. package/dist/tools/calendar.js +18 -14
  79. package/dist/tools/chat.d.ts +10 -1
  80. package/dist/tools/chat.js +13 -8
  81. package/dist/tools/contacts.d.ts +2 -1
  82. package/dist/tools/contacts.js +17 -13
  83. package/dist/tools/docs.d.ts +2 -1
  84. package/dist/tools/docs.js +34 -30
  85. package/dist/tools/drive.d.ts +2 -1
  86. package/dist/tools/drive.js +65 -50
  87. package/dist/tools/forms.d.ts +10 -1
  88. package/dist/tools/forms.js +19 -12
  89. package/dist/tools/generated/_shared.d.ts +2 -1
  90. package/dist/tools/generated/_shared.js +7 -3
  91. package/dist/tools/generated/admin.d.ts +2 -1
  92. package/dist/tools/generated/admin.js +265 -265
  93. package/dist/tools/generated/analytics.d.ts +2 -1
  94. package/dist/tools/generated/analytics.js +125 -125
  95. package/dist/tools/generated/appsmarket.d.ts +2 -1
  96. package/dist/tools/generated/appsmarket.js +5 -5
  97. package/dist/tools/generated/calendar.d.ts +2 -1
  98. package/dist/tools/generated/calendar.js +55 -55
  99. package/dist/tools/generated/chat.d.ts +2 -1
  100. package/dist/tools/generated/chat.js +95 -95
  101. package/dist/tools/generated/classroom.d.ts +2 -1
  102. package/dist/tools/generated/classroom.js +209 -209
  103. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  104. package/dist/tools/generated/cloudidentity.js +133 -133
  105. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  106. package/dist/tools/generated/cloudsearch.js +99 -99
  107. package/dist/tools/generated/contacts.d.ts +2 -1
  108. package/dist/tools/generated/contacts.js +29 -29
  109. package/dist/tools/generated/drive.d.ts +2 -1
  110. package/dist/tools/generated/drive.js +71 -71
  111. package/dist/tools/generated/driveactivity.d.ts +2 -1
  112. package/dist/tools/generated/driveactivity.js +3 -3
  113. package/dist/tools/generated/drivelabels.d.ts +2 -1
  114. package/dist/tools/generated/drivelabels.js +53 -53
  115. package/dist/tools/generated/forms.d.ts +2 -1
  116. package/dist/tools/generated/forms.js +7 -7
  117. package/dist/tools/generated/gmail.d.ts +2 -1
  118. package/dist/tools/generated/gmail.js +117 -117
  119. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  120. package/dist/tools/generated/groupsmigration.js +3 -3
  121. package/dist/tools/generated/groupssettings.d.ts +2 -1
  122. package/dist/tools/generated/groupssettings.js +7 -7
  123. package/dist/tools/generated/index.d.ts +2 -1
  124. package/dist/tools/generated/keep.d.ts +2 -1
  125. package/dist/tools/generated/keep.js +15 -15
  126. package/dist/tools/generated/licensing.d.ts +2 -1
  127. package/dist/tools/generated/licensing.js +15 -15
  128. package/dist/tools/generated/meet.d.ts +2 -1
  129. package/dist/tools/generated/meet.js +27 -27
  130. package/dist/tools/generated/postmaster.d.ts +2 -1
  131. package/dist/tools/generated/postmaster.js +9 -9
  132. package/dist/tools/generated/reseller.d.ts +2 -1
  133. package/dist/tools/generated/reseller.js +35 -35
  134. package/dist/tools/generated/script.d.ts +2 -1
  135. package/dist/tools/generated/script.js +33 -33
  136. package/dist/tools/generated/searchconsole.d.ts +2 -1
  137. package/dist/tools/generated/searchconsole.js +3 -3
  138. package/dist/tools/generated/sheets.d.ts +2 -1
  139. package/dist/tools/generated/sheets.js +15 -15
  140. package/dist/tools/generated/tasks.d.ts +2 -1
  141. package/dist/tools/generated/tasks.js +5 -5
  142. package/dist/tools/generated/vault.d.ts +2 -1
  143. package/dist/tools/generated/vault.js +67 -67
  144. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  145. package/dist/tools/generated/workspaceevents.js +31 -31
  146. package/dist/tools/gmail.d.ts +2 -1
  147. package/dist/tools/gmail.js +45 -33
  148. package/dist/tools/google-api.d.ts +2 -3
  149. package/dist/tools/google-api.js +30 -16
  150. package/dist/tools/meet.d.ts +10 -1
  151. package/dist/tools/meet.js +13 -9
  152. package/dist/tools/searchconsole.d.ts +2 -1
  153. package/dist/tools/searchconsole.js +17 -13
  154. package/dist/tools/sheets.d.ts +2 -1
  155. package/dist/tools/sheets.js +36 -32
  156. package/dist/tools/slides.d.ts +10 -1
  157. package/dist/tools/slides.js +17 -10
  158. package/dist/tools/tasks.d.ts +2 -1
  159. package/dist/tools/tasks.js +19 -15
  160. package/dist/trim.d.ts +5 -0
  161. package/dist/trim.js +20 -0
  162. package/dist/usage-metrics.d.ts +23 -0
  163. package/dist/usage-metrics.js +65 -47
  164. package/dist/write-control.js +10 -2
  165. package/package.json +27 -1
@@ -0,0 +1,117 @@
1
+ // H4 F6: GET /authorize is unauthenticated, and a client_id on an allowlisted
2
+ // issuer with a path never seen before forces an outbound fetch. So the
3
+ // fetches running at once are capped, one client_id is fetched once however
4
+ // many requests wait on it, and only a document that passed validation is
5
+ // kept. Errors are never cached (CIMD section 5.2), and a negative cache would
6
+ // not help anyway: the attack varies the path, so its key never repeats.
7
+ import { CimdHttpError, isTransientFetchError, SsrfBlockedError } from './ssrf-guard.js';
8
+ import { logSafe } from './trim.js';
9
+ export const CIMD_TTL_MS = 5 * 60_000;
10
+ /** The oldest validated document still served while the cap is full or the issuer cannot answer. */
11
+ export const CIMD_MAX_STALE_MS = 24 * 60 * 60_000;
12
+ /** Fetches at once. A lookup can outlive the fetch that gave up on it, so ssrf-guard caps its own lookups apart. */
13
+ export const CIMD_MAX_IN_FLIGHT = 4;
14
+ export const CIMD_CACHE_MAX = 256;
15
+ const BUSY_LOG_EVERY_MS = 60_000;
16
+ export class CimdClientCache {
17
+ deps;
18
+ entries = new Map();
19
+ // One entry per running fetch, so its size is the number of permits taken.
20
+ inFlight = new Map();
21
+ busyLoggedAt = -Infinity;
22
+ busySuppressed = 0;
23
+ constructor(deps) {
24
+ this.deps = deps;
25
+ }
26
+ get size() {
27
+ return this.entries.size;
28
+ }
29
+ /** `validate` runs once per fetch, in the request that started it, and every
30
+ * request waiting on that fetch gets the same answer. */
31
+ async resolve(clientId, validate) {
32
+ const entry = this.entries.get(clientId);
33
+ const age = entry ? this.deps.now() - entry.at : 0;
34
+ if (entry && age < CIMD_TTL_MS)
35
+ return { doc: entry.doc, fresh: false };
36
+ if (entry && age >= CIMD_MAX_STALE_MS)
37
+ this.entries.delete(clientId);
38
+ const running = this.inFlight.get(clientId);
39
+ if (running) {
40
+ const out = await running;
41
+ return 'doc' in out ? { doc: out.doc, fresh: false } : out;
42
+ }
43
+ if (this.inFlight.size >= CIMD_MAX_IN_FLIGHT) {
44
+ // A flood of fresh paths is exactly when the cap is full, and a client
45
+ // that signed in before must still be able to.
46
+ const stale = this.entries.get(clientId);
47
+ if (stale)
48
+ return { doc: stale.doc, fresh: false };
49
+ this.noteBusy();
50
+ return { busy: true };
51
+ }
52
+ return this.fetchFor(clientId, validate);
53
+ }
54
+ async fetchFor(clientId, validate) {
55
+ // Never rejects: neither core nor a host installs an unhandledRejection
56
+ // handler, and a waiter that stopped listening must not take the process down.
57
+ let settle;
58
+ this.inFlight.set(clientId, new Promise((r) => (settle = r)));
59
+ let out = { error: 'E_CIMD_INVALID' };
60
+ try {
61
+ const doc = await this.deps.fetch(clientId);
62
+ if (validate(doc)) {
63
+ this.store(clientId, doc);
64
+ out = { doc, fresh: true };
65
+ }
66
+ else {
67
+ this.entries.delete(clientId);
68
+ }
69
+ }
70
+ catch (e) {
71
+ out = { error: e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID' };
72
+ // A document that went bad is never served stale. An issuer that is down
73
+ // or overloaded says nothing about the document, so the last good one
74
+ // stays and answers this fetch's requests too (stale-if-error; the error
75
+ // itself is never stored).
76
+ const stale = this.entries.get(clientId);
77
+ if (!saysNothingAboutDocument(e))
78
+ this.entries.delete(clientId);
79
+ else if (stale && this.deps.now() - stale.at < CIMD_MAX_STALE_MS)
80
+ out = { doc: stale.doc, fresh: false };
81
+ // #13: the detail goes to the log only; the caller gets a generic answer.
82
+ this.deps.log(`CIMD fetch failed for ${logSafe(clientId, 128)}: ${logSafe(e instanceof Error ? e.message : 'non-Error rejection', 200)}`);
83
+ }
84
+ finally {
85
+ this.inFlight.delete(clientId);
86
+ settle(out);
87
+ }
88
+ return out;
89
+ }
90
+ store(clientId, doc) {
91
+ this.entries.delete(clientId);
92
+ while (this.entries.size >= CIMD_CACHE_MAX) {
93
+ const oldest = this.entries.keys().next().value;
94
+ if (oldest === undefined)
95
+ break;
96
+ this.entries.delete(oldest);
97
+ }
98
+ this.entries.set(clientId, { doc, at: this.deps.now() });
99
+ }
100
+ // Over-cap requests arrive at whatever rate the caller likes.
101
+ noteBusy() {
102
+ const now = this.deps.now();
103
+ if (now >= this.busyLoggedAt && now - this.busyLoggedAt < BUSY_LOG_EVERY_MS) {
104
+ this.busySuppressed++;
105
+ return;
106
+ }
107
+ const suppressed = this.busySuppressed;
108
+ this.busyLoggedAt = now;
109
+ this.busySuppressed = 0;
110
+ this.deps.log(`CIMD busy: in-flight cap reached${suppressed > 0 ? ` (n=${suppressed} suppressed)` : ''}`);
111
+ }
112
+ }
113
+ function saysNothingAboutDocument(e) {
114
+ if (isTransientFetchError(e))
115
+ return true;
116
+ return e instanceof CimdHttpError && (e.status === 408 || e.status === 429 || e.status >= 500);
117
+ }
package/dist/client.d.ts CHANGED
@@ -1,3 +1,33 @@
1
1
  import { OAuth2Client } from 'googleapis-common';
2
- import type { Account } from './accounts.js';
3
- export declare function getClient(account: Account): Promise<OAuth2Client>;
2
+ import type { Account, AccountSet } from './accounts.js';
3
+ import type { TokenData } from './types.js';
4
+ export interface GetClientDeps {
5
+ /** Live account view — read at call time, never a captured snapshot. */
6
+ accounts: () => AccountSet;
7
+ readToken: (alias: string) => TokenData | null;
8
+ updateToken: (alias: string, updates: object) => void;
9
+ /** Cross-process staleness probe (single-owner global registry only). */
10
+ refreshIfStale?: () => void;
11
+ }
12
+ /** Test-only visibility into the dedupe map. */
13
+ export declare function __inflightSizeForTest(): number;
14
+ /**
15
+ * Per-context client factory: `contextKey` namespaces the single-flight map
16
+ * (one lane per tenant), `deps` carry that context's account view and token
17
+ * custody. The free core builds exactly one, bound to the global registry.
18
+ */
19
+ export declare function makeGetClient(contextKey: string, deps: GetClientDeps): (account: Account) => Promise<OAuth2Client>;
20
+ /** Injection surface for curated tool files: absent = the single-owner
21
+ * getClient below (today's behavior); a tenant context passes its own. */
22
+ export interface CuratedToolDeps {
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;
28
+ }
29
+ /** The single-owner client, bound to the global registry + token store. The
30
+ * operator surfaces that never carry a tenant (api-probe, executor and
31
+ * escape-hatch `deps.getClientFn ?? getClient` defaults) resolve through
32
+ * this. */
33
+ export declare const getClient: (account: Account) => Promise<OAuth2Client>;
package/dist/client.js CHANGED
Binary file
@@ -0,0 +1,11 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import type { ServerTarget } from './http-transport.js';
3
+ import { ToolRegistry, type DiscoveryMode } from './registry.js';
4
+ import { type IdentityContext } from './identity.js';
5
+ import type { Metrics } from './usage-metrics.js';
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'>;
@@ -0,0 +1,109 @@
1
+ // S1.12: registry composition, extracted from index.ts so the tenancy module
2
+ // (and tests) can build registries without touching the CLI entrypoint. This
3
+ // module must stay free of import-time side effects of its own: no argv
4
+ // parsing, no process.exit, no prints (accounts.js module-load resolution is
5
+ // the one inherited, pre-existing exception on the import chain).
6
+ import { GENERATED_SERVICES } from './tools/generated/index.js';
7
+ import { GENERATED_GATES, SERVICES, servicesAwaitingRestart, unknownToolMessage } from './services.js';
8
+ import { argNormalizationEnabled } from './arg-normalize.js';
9
+ import { unknownArgMode } from './arg-strict.js';
10
+ import { ToolRegistry } from './registry.js';
11
+ import { registerDiscoverTools } from './discover.js';
12
+ import { registerEscapeTools } from './tools/google-api.js';
13
+ import { accountHealthDepsFor, registerAccountTools } from './tools/accounts-tool.js';
14
+ import { registerDiagnoseTool } from './doctor.js';
15
+ import { registerAccountWizardTools } from './tools/account-wizard.js';
16
+ import { getToolsets, toolsetEnabled } from './toolsets.js';
17
+ import { isOwnerContext } from './identity.js';
18
+ import { resolveScopesForAccount } from './auth.js';
19
+ export function buildRegistry(server, ctx, mode, metrics = null) {
20
+ const policy = ctx.policy;
21
+ // The registry's account view IS the context's: fan-out expansion,
22
+ // selector validation and default-account injection all follow this
23
+ // context, not the process global (identical for the free core's one ctx).
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
+ };
37
+ const toolsets = getToolsets();
38
+ if (toolsets !== 'all') {
39
+ const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
40
+ for (const requested of toolsets) {
41
+ if (!known.has(requested)) {
42
+ process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
43
+ }
44
+ }
45
+ }
46
+ for (const svc of SERVICES) {
47
+ if (!toolsetEnabled(toolsets, svc.name))
48
+ continue;
49
+ if (svc.enabled && !svc.enabled(ctx.accounts)) {
50
+ if (toolsets !== 'all') {
51
+ const hint = svc.name === 'admin' ? 'set admin on an account/profile (or GOOGLE_ADMIN_ACCOUNTS)' : `add "${svc.name}" to an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES)`;
52
+ process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
53
+ }
54
+ continue;
55
+ }
56
+ svc.register(registry, clientDeps);
57
+ }
58
+ for (const gen of GENERATED_SERVICES) {
59
+ if (!toolsetEnabled(toolsets, gen.name))
60
+ continue;
61
+ const curated = SERVICES.find((s) => s.name === gen.name);
62
+ const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
63
+ if (gate && !gate(ctx.accounts)) {
64
+ if (!curated && toolsets !== 'all') {
65
+ process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
66
+ }
67
+ continue;
68
+ }
69
+ gen.register(registry, execDeps);
70
+ }
71
+ if (registry.services().length === 0) {
72
+ const known = [...new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)])].sort();
73
+ throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
74
+ `Known services: ${known.join(', ')}. ` +
75
+ `Note: optional services need their bundle in an account's scope profile (or legacy GOOGLE_OPTIONAL_SCOPES); admin needs an admin account/profile.`);
76
+ }
77
+ registerDiscoverTools(registry, policy);
78
+ registerEscapeTools(registry, policy, execDeps);
79
+ registerAccountTools(registry, accountHealthDepsFor(ctx.tokenStore));
80
+ registerDiagnoseTool(registry, ctx);
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, () => servicesAwaitingRestart(registry));
85
+ return registry;
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
+ }
@@ -1,7 +1,20 @@
1
1
  export declare const CONFIG_VERSION = 1;
2
2
  export declare function configDir(env?: NodeJS.ProcessEnv): string;
3
3
  export declare function configFilePath(env?: NodeJS.ProcessEnv): string;
4
+ export declare function tenantsDir(env?: NodeJS.ProcessEnv): string;
5
+ export declare function tenantConfigFilePath(tenantId: string, env?: NodeJS.ProcessEnv): string;
6
+ export declare function tenantTokenDir(tenantId: string, env?: NodeJS.ProcessEnv): string;
7
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
8
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
9
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
10
+ * tenant provisions first) must never stay group-readable. */
11
+ export declare function ensureTenantDirs(tenantId: string, env?: NodeJS.ProcessEnv): {
12
+ configFile: string;
13
+ tokenDir: string;
14
+ };
4
15
  export declare const ALIAS_RE: RegExp;
16
+ export declare const RESERVED_ALIASES: string[];
17
+ export declare function isReservedAlias(name: string): boolean;
5
18
  export interface ConfigFile {
6
19
  version: number;
7
20
  accounts?: Record<string, {
@@ -10,6 +10,39 @@ export function configDir(env = process.env) {
10
10
  export function configFilePath(env = process.env) {
11
11
  return path.join(configDir(env), 'config.json');
12
12
  }
13
+ export function tenantsDir(env = process.env) {
14
+ return path.join(configDir(env), 'tenants');
15
+ }
16
+ // A tenant id becomes a filesystem path segment exactly like an account
17
+ // alias, so it rides the same traversal guard. 'owner' is the single-owner
18
+ // context's subject and client lane, so no tenant may take it.
19
+ function assertTenantId(tenantId) {
20
+ if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId) || tenantId === 'owner') {
21
+ throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
22
+ }
23
+ }
24
+ export function tenantConfigFilePath(tenantId, env = process.env) {
25
+ assertTenantId(tenantId);
26
+ return path.join(tenantsDir(env), tenantId, 'config.json');
27
+ }
28
+ export function tenantTokenDir(tenantId, env = process.env) {
29
+ assertTenantId(tenantId);
30
+ return path.join(tenantsDir(env), tenantId, 'tokens');
31
+ }
32
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
33
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
34
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
35
+ * tenant provisions first) must never stay group-readable. */
36
+ export function ensureTenantDirs(tenantId, env = process.env) {
37
+ const tokenDir = tenantTokenDir(tenantId, env);
38
+ const levels = [configDir(env), tenantsDir(env), path.join(tenantsDir(env), tenantId), tokenDir];
39
+ for (const dir of levels) {
40
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ if (process.platform !== 'win32')
42
+ fs.chmodSync(dir, 0o700);
43
+ }
44
+ return { configFile: tenantConfigFilePath(tenantId, env), tokenDir };
45
+ }
13
46
  // Alias charset is load-bearing security, not cosmetics: the alias becomes a
14
47
  // tokenDir path segment (encPath), so this guard blocks path traversal.
15
48
  export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
@@ -22,7 +55,10 @@ const accountEntrySchema = z.strictObject({
22
55
  // masterKey, jwtKey, clientId) fails validation, structurally enforcing
23
56
  // secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
24
57
  // of the frozen envelope; their consumers land in later slices.
25
- const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
58
+ export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
59
+ export function isReservedAlias(name) {
60
+ return RESERVED_ALIASES.includes(name);
61
+ }
26
62
  const configSchema = z.strictObject({
27
63
  version: z.number().int(),
28
64
  accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
package/dist/discover.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
+ import { hints } from './hint-provider.js';
3
4
  // The description budget is the whole point of lazy mode: a full op list per
4
5
  // service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
5
6
  // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
@@ -91,6 +92,7 @@ export function registerDiscoverTools(registry, policy) {
91
92
  }, async ({ query }) => {
92
93
  const operations = registry.catalog(service, query);
93
94
  registry.reveal(service);
95
+ const rendered = hints()?.render({ kind: 'policy_summary', policy });
94
96
  return {
95
97
  content: [
96
98
  {
@@ -98,7 +100,7 @@ export function registerDiscoverTools(registry, policy) {
98
100
  text: JSON.stringify({
99
101
  service,
100
102
  operations,
101
- writeControl: describePolicy(policy),
103
+ writeControl: rendered?.hint ?? describePolicy(policy),
102
104
  next: operations.length > 0
103
105
  ? 'Call the chosen tool by name; it is now listed and callable.'
104
106
  : `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
@@ -49,5 +49,8 @@ export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Prom
49
49
  export declare function clearDiscoveryMemoryCache(): void;
50
50
  export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
51
51
  export declare function expandPath(template: string, pathParams: Record<string, string>): string;
52
+ /** Search keywords are a few words; the caps keep a caller-sized query from
53
+ * driving index-size x token-count substring scans on the event loop. */
54
+ export declare const MAX_SEARCH_QUERY = 500;
52
55
  export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
53
56
  export {};
@@ -74,6 +74,21 @@ export function discoveryCacheDir(env = process.env) {
74
74
  return (env.DISCOVERY_CACHE_PATH ??
75
75
  path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
76
76
  }
77
+ // Default cache path only (an operator's DISCOVERY_CACHE_PATH may be shared,
78
+ // even /tmp, and keeps its mode), only when this process owns it, and best
79
+ // effort. Clears group/other bits and keeps setuid/setgid/sticky.
80
+ function tightenOwnDir(dir) {
81
+ if (process.platform === 'win32')
82
+ return;
83
+ try {
84
+ const st = fs.statSync(dir);
85
+ if (st.uid === process.getuid?.() && (st.mode & 0o077) !== 0)
86
+ fs.chmodSync(dir, st.mode & ~0o077 & 0o7777);
87
+ }
88
+ catch {
89
+ // the cache still works with the directory's own mode
90
+ }
91
+ }
77
92
  // A poisoned cache file could repoint baseUrl off-Google; refuse to send the
78
93
  // user's Bearer token anywhere but a googleapis.com host.
79
94
  export function isGoogleApiUrl(url) {
@@ -135,6 +150,7 @@ export async function loadMethodIndex(api, deps = {}) {
135
150
  throw new Error(`Unknown api "${api}". Known: ${Object.keys(SUPPORTED_APIS).join(', ')}`);
136
151
  }
137
152
  const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
153
+ const defaultDir = deps.cacheDir === undefined && process.env.DISCOVERY_CACHE_PATH === undefined;
138
154
  const cacheDir = deps.cacheDir ?? discoveryCacheDir();
139
155
  const cacheFile = path.join(cacheDir, `${api}.json`);
140
156
  const readCache = () => {
@@ -172,7 +188,9 @@ export async function loadMethodIndex(api, deps = {}) {
172
188
  if (!res.ok)
173
189
  throw new Error(`HTTP ${res.status}`);
174
190
  doc = (await res.json());
175
- fs.mkdirSync(cacheDir, { recursive: true });
191
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
192
+ if (defaultDir)
193
+ tightenOwnDir(cacheDir);
176
194
  fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
177
195
  break;
178
196
  }
@@ -263,8 +281,13 @@ export function expandPath(template, pathParams) {
263
281
  return segments.map(encodeURIComponent).join('/');
264
282
  });
265
283
  }
284
+ /** Search keywords are a few words; the caps keep a caller-sized query from
285
+ * driving index-size x token-count substring scans on the event loop. */
286
+ export const MAX_SEARCH_QUERY = 500;
287
+ const MAX_SEARCH_TOKENS = 16;
266
288
  export function searchMethods(index, query, limit = 10) {
267
- const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
289
+ const words = query.slice(0, MAX_SEARCH_QUERY).toLowerCase().split(/\s+/).filter(Boolean);
290
+ const tokens = [...new Set(words)].slice(0, MAX_SEARCH_TOKENS);
268
291
  if (tokens.length === 0)
269
292
  return index.slice(0, limit);
270
293
  const scored = index
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';
@@ -58,7 +59,9 @@ export declare function apiEnableLink(api: string): string;
58
59
  export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
59
60
  /** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
60
61
  export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
61
- export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
62
+ export declare function runDiagnostics(deps?: DiagnosticsDeps, opts?: {
63
+ scope?: 'operator' | 'tenant';
64
+ }): Promise<DiagnosticsReport>;
62
65
  /** doctor CLI (brew-doctor style): glyph-coded human report. */
63
66
  export declare function renderDoctorText(report: DiagnosticsReport): string;
64
67
  /** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
@@ -66,6 +69,7 @@ export declare function renderDoctorText(report: DiagnosticsReport): string;
66
69
  export declare function renderReport(report: DiagnosticsReport, version: string): string;
67
70
  /** Exit non-zero when any section FAILs; with strict, WARN counts too. */
68
71
  export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
72
+ export type DiagnoseContext = Pick<IdentityContext, 'subject' | 'accounts' | 'getClient' | 'tokenStore'>;
69
73
  /** Agent-callable structured health report (read-only). Mirrors `doctor`'s
70
74
  * engine. Registered as a META tool, like `account_list`: it introspects this
71
75
  * server rather than Google data, and as a normal tool it became a service of
@@ -73,7 +77,7 @@ export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean):
73
77
  * from already-registered services, and this runs after that), so it was
74
78
  * advertised only once something else expanded the surface. The README sends
75
79
  * people here when they are stuck, so it has to be findable. */
76
- export declare function registerDiagnoseTool(registry: ToolRegistry): void;
80
+ export declare function registerDiagnoseTool(registry: ToolRegistry, ctx?: DiagnoseContext): void;
77
81
  export interface ResetOptions {
78
82
  account?: string;
79
83
  regenerateKey: boolean;