mcp-google-multi 6.0.0-alpha.4 → 6.0.0-alpha.41

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 (120) hide show
  1. package/README.md +6 -4
  2. package/dist/accounts.d.ts +9 -1
  3. package/dist/accounts.js +24 -12
  4. package/dist/api-probe.js +3 -0
  5. package/dist/arg-normalize.d.ts +76 -0
  6. package/dist/arg-normalize.js +314 -0
  7. package/dist/arg-strict.d.ts +54 -0
  8. package/dist/arg-strict.js +163 -0
  9. package/dist/auth.js +15 -72
  10. package/dist/boot-gates.d.ts +26 -0
  11. package/dist/boot-gates.js +38 -0
  12. package/dist/client.js +15 -5
  13. package/dist/config-file.d.ts +14 -0
  14. package/dist/config-file.js +40 -1
  15. package/dist/discover.js +35 -14
  16. package/dist/discovery-client.d.ts +8 -1
  17. package/dist/discovery-client.js +96 -17
  18. package/dist/doctor.d.ts +21 -1
  19. package/dist/doctor.js +127 -20
  20. package/dist/env-load.d.ts +11 -0
  21. package/dist/env-load.js +23 -1
  22. package/dist/executor.js +12 -1
  23. package/dist/fanout.d.ts +1 -1
  24. package/dist/fanout.js +49 -11
  25. package/dist/http-transport.d.ts +27 -3
  26. package/dist/http-transport.js +52 -12
  27. package/dist/identity.d.ts +3 -1
  28. package/dist/index.js +145 -23
  29. package/dist/master-key.js +11 -5
  30. package/dist/mcp-token.d.ts +17 -6
  31. package/dist/mcp-token.js +8 -6
  32. package/dist/metrics-cli.d.ts +21 -0
  33. package/dist/metrics-cli.js +206 -0
  34. package/dist/metrics-tap.d.ts +12 -0
  35. package/dist/metrics-tap.js +95 -0
  36. package/dist/oauth-as.d.ts +31 -0
  37. package/dist/oauth-as.js +64 -10
  38. package/dist/oauth-consent.d.ts +25 -10
  39. package/dist/oauth-consent.js +85 -39
  40. package/dist/outbound-allowlist.d.ts +34 -0
  41. package/dist/outbound-allowlist.js +108 -0
  42. package/dist/registry-transfer.js +1 -2
  43. package/dist/registry.d.ts +56 -2
  44. package/dist/registry.js +205 -15
  45. package/dist/scope-catalog.d.ts +1 -0
  46. package/dist/scope-catalog.js +17 -1
  47. package/dist/scope-observability.js +2 -1
  48. package/dist/services.d.ts +8 -0
  49. package/dist/services.js +28 -1
  50. package/dist/setup-prompt.d.ts +1 -1
  51. package/dist/setup-prompt.js +1 -1
  52. package/dist/token-store.d.ts +3 -2
  53. package/dist/token-store.js +0 -0
  54. package/dist/tools/_coerce.d.ts +5 -0
  55. package/dist/tools/_coerce.js +11 -0
  56. package/dist/tools/_errors.d.ts +31 -3
  57. package/dist/tools/_errors.js +307 -43
  58. package/dist/tools/_local-files.js +7 -2
  59. package/dist/tools/account-wizard.d.ts +24 -1
  60. package/dist/tools/account-wizard.js +152 -58
  61. package/dist/tools/accounts-tool.js +3 -2
  62. package/dist/tools/admin.js +7 -6
  63. package/dist/tools/analytics.d.ts +18 -0
  64. package/dist/tools/analytics.js +284 -0
  65. package/dist/tools/calendar.d.ts +7 -0
  66. package/dist/tools/calendar.js +75 -15
  67. package/dist/tools/chat.js +7 -4
  68. package/dist/tools/contacts.js +57 -30
  69. package/dist/tools/docs.js +56 -54
  70. package/dist/tools/drive.d.ts +2 -0
  71. package/dist/tools/drive.js +200 -91
  72. package/dist/tools/forms.js +7 -7
  73. package/dist/tools/generated/_shared.d.ts +6 -0
  74. package/dist/tools/generated/_shared.js +11 -1
  75. package/dist/tools/generated/admin.js +345 -214
  76. package/dist/tools/generated/analytics.d.ts +2 -0
  77. package/dist/tools/generated/analytics.js +981 -0
  78. package/dist/tools/generated/appsmarket.js +4 -4
  79. package/dist/tools/generated/calendar.js +28 -28
  80. package/dist/tools/generated/chat.js +83 -56
  81. package/dist/tools/generated/classroom.js +258 -216
  82. package/dist/tools/generated/cloudidentity.js +76 -68
  83. package/dist/tools/generated/cloudsearch.js +40 -39
  84. package/dist/tools/generated/contacts.js +20 -11
  85. package/dist/tools/generated/drive.js +62 -53
  86. package/dist/tools/generated/drivelabels.js +60 -32
  87. package/dist/tools/generated/forms.js +7 -7
  88. package/dist/tools/generated/gmail.js +136 -109
  89. package/dist/tools/generated/groupsmigration.js +1 -1
  90. package/dist/tools/generated/groupssettings.js +3 -3
  91. package/dist/tools/generated/index.js +2 -0
  92. package/dist/tools/generated/keep.js +8 -7
  93. package/dist/tools/generated/licensing.js +39 -23
  94. package/dist/tools/generated/meet.js +14 -14
  95. package/dist/tools/generated/method-map.d.ts +2 -0
  96. package/dist/tools/generated/method-map.js +894 -0
  97. package/dist/tools/generated/postmaster.js +3 -3
  98. package/dist/tools/generated/reseller.js +34 -28
  99. package/dist/tools/generated/script.js +29 -19
  100. package/dist/tools/generated/searchconsole.js +5 -3
  101. package/dist/tools/generated/sheets.js +10 -9
  102. package/dist/tools/generated/tasks.js +10 -4
  103. package/dist/tools/generated/vault.js +65 -56
  104. package/dist/tools/generated/workspaceevents.js +17 -16
  105. package/dist/tools/gmail.d.ts +10 -1
  106. package/dist/tools/gmail.js +50 -18
  107. package/dist/tools/google-api.d.ts +4 -1
  108. package/dist/tools/google-api.js +73 -19
  109. package/dist/tools/meet.js +5 -2
  110. package/dist/tools/searchconsole.js +10 -10
  111. package/dist/tools/sheets.js +36 -48
  112. package/dist/tools/slides.js +6 -6
  113. package/dist/tools/tasks.js +16 -16
  114. package/dist/trim.d.ts +21 -0
  115. package/dist/trim.js +47 -0
  116. package/dist/usage-metrics.d.ts +161 -0
  117. package/dist/usage-metrics.js +609 -0
  118. package/dist/write-control.d.ts +27 -1
  119. package/dist/write-control.js +90 -11
  120. package/package.json +27 -19
@@ -0,0 +1,163 @@
1
+ // Unknown-argument screening (backlog item 14). zod strips undeclared keys
2
+ // before a handler runs, so a misremembered argument name used to produce a
3
+ // SUCCESS response with wrong behavior: `parentId` on drive_create_folder was
4
+ // dropped and the folder landed in My Drive root. Field-reported, then
5
+ // reproduced: the typo, the correct name and a wholly invented key all
6
+ // returned byte-identical responses.
7
+ //
8
+ // This module is pure and holds no registry reference. It never rewrites a key
9
+ // and never touches a VALUE: it only decides that a key is undeclared, and
10
+ // suggests what the caller probably meant. Rewriting would risk sending a real
11
+ // value to Google on a guess, which is strictly worse than refusing.
12
+ import { editDistance } from './scope-catalog.js';
13
+ /**
14
+ * `GOOGLE_ARG_UNKNOWN`: reject | warn | off. A typo in the VALUE still falls
15
+ * back to `warn`, not to the default: an operator who misspells the setting
16
+ * has said nothing about which behavior they want, and guessing the strict
17
+ * one would turn a config typo into failing tool calls.
18
+ */
19
+ export function unknownArgMode(env = process.env) {
20
+ const raw = (env.GOOGLE_ARG_UNKNOWN ?? '').trim().toLowerCase();
21
+ if (raw === '')
22
+ return DEFAULT_MODE;
23
+ if (raw === 'reject' || raw === 'warn' || raw === 'off')
24
+ return raw;
25
+ process.stderr.write(`GOOGLE_ARG_UNKNOWN="${raw}" is not valid (reject | warn | off); using warn\n`);
26
+ return 'warn';
27
+ }
28
+ // The staged rollout is over: `warn` shipped first as pure observability, and
29
+ // this is the flip it was staging for. An undeclared argument is dropped by
30
+ // zod before the handler runs, so `warn` means the CLIENT sees a confident
31
+ // success for a call the server did not perform: drive_list with a misspelled
32
+ // folder key returned the My Drive root, byte-identical to no argument at all.
33
+ // A wrong answer that reads as right is the failure mode agents recover from
34
+ // worst, so the default refuses the call and says what it probably meant.
35
+ const DEFAULT_MODE = 'reject';
36
+ /** Tools that legitimately accept open-ended top-level keys. Empty at 6.0.0:
37
+ * the escape hatch is NOT one, because its open-endedness lives in the VALUES
38
+ * of queryParams/body, never in its six fixed top-level keys. */
39
+ export const STRICT_EXEMPT_TOOLS = new Set();
40
+ /** Keys a CLIENT adds on its own, not keys the model chose. `random_string`
41
+ * is the long-standing probe some clients send to a tool they read as taking
42
+ * no arguments; the others are call-plumbing that bridges and proxies have
43
+ * been observed to fold into `arguments` instead of `params._meta`, where the
44
+ * spec puts them. Rejecting these would fail a call the model got right.
45
+ * Measured against the full 978-tool surface: none is a declared key. */
46
+ const CLIENT_ARTIFACT_KEYS = new Set([
47
+ 'random_string',
48
+ 'toolCallId',
49
+ 'tool_call_id',
50
+ 'tool_call_description',
51
+ ]);
52
+ /** Metadata a client may legitimately attach. Measured against all registered
53
+ * tools: no declared key starts with `_` or contains `/`, so neither rule can
54
+ * shadow a real parameter. */
55
+ export function isExemptKey(key) {
56
+ return key.startsWith('_') || key.includes('/') || CLIENT_ARTIFACT_KEYS.has(key);
57
+ }
58
+ /** Generic words that must never be offered as a suggestion on containment
59
+ * alone; they match far too much to be useful. */
60
+ const GENERIC = new Set(['id', 'name', 'title', 'body', 'parent', 'text', 'content', 'type', 'value', 'key', 'data']);
61
+ const flatten = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
62
+ function tokens(s) {
63
+ return s
64
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
65
+ .split(/[\s_\-.]+/)
66
+ .map((t) => t.toLowerCase())
67
+ .filter(Boolean);
68
+ }
69
+ /**
70
+ * Rank declared keys for an unknown key. Tiers, best non-empty tier wins:
71
+ * 0 same key modulo case and separators
72
+ * 1 every token of the unknown key appears in the declared key
73
+ * 2 substring containment of the flattened forms
74
+ * 3 a genuine typo by edit distance
75
+ * Plain edit distance alone cannot carry this: parentid -> parentfolderid is
76
+ * distance 6, well past any sane threshold, which is why tier 1 exists.
77
+ */
78
+ export function suggestKeys(unknown, declared, limit = 2) {
79
+ const uFlat = flatten(unknown);
80
+ const uTok = tokens(unknown);
81
+ const tiers = [[], [], [], []];
82
+ for (const key of declared) {
83
+ const kFlat = flatten(key);
84
+ const kTok = tokens(key);
85
+ if (kFlat === uFlat) {
86
+ tiers[0].push({ key, rank: 0 });
87
+ continue;
88
+ }
89
+ if (uTok.length >= 2 && uTok.every((t) => kTok.includes(t))) {
90
+ tiers[1].push({ key, rank: Math.abs(kTok.length - uTok.length) });
91
+ continue;
92
+ }
93
+ if (uFlat.length >= 4 && !GENERIC.has(kFlat) && (kFlat.includes(uFlat) || uFlat.includes(kFlat))) {
94
+ tiers[2].push({ key, rank: Math.abs(kFlat.length - uFlat.length) });
95
+ continue;
96
+ }
97
+ const d = editDistance(uFlat, kFlat);
98
+ if (d <= Math.max(1, Math.floor(uFlat.length / 4)))
99
+ tiers[3].push({ key, rank: d });
100
+ }
101
+ const tier = tiers.find((t) => t.length > 0);
102
+ if (!tier)
103
+ return [];
104
+ // Stable: declaration order breaks rank ties.
105
+ return tier
106
+ .map((e, i) => ({ ...e, i }))
107
+ .sort((a, b) => a.rank - b.rank || a.i - b.i)
108
+ .slice(0, limit)
109
+ .map((e) => e.key);
110
+ }
111
+ /**
112
+ * Split a call's post-rename keys into declared, redundant-duplicate and
113
+ * genuinely unknown. `declared` is the tool's full declared key list.
114
+ */
115
+ export function screenArguments(tool, args, declared) {
116
+ const out = { unknown: [], redundant: [] };
117
+ if (STRICT_EXEMPT_TOOLS.has(tool) || declared.length === 0)
118
+ return out;
119
+ const declaredSet = new Set(declared);
120
+ for (const key of Object.keys(args)) {
121
+ if (declaredSet.has(key) || isExemptKey(key))
122
+ continue;
123
+ const suggestions = suggestKeys(key, declared);
124
+ // The caller sent both spellings, so the declared one already won and the
125
+ // outcome is what it has always been. Never fail a call that works today.
126
+ if (suggestions.some((s) => s in args)) {
127
+ out.redundant.push(key);
128
+ continue;
129
+ }
130
+ out.unknown.push({ sent: key, suggestions });
131
+ }
132
+ return out;
133
+ }
134
+ /** The agent-facing envelope. Values are never included, only key names. */
135
+ export function unknownArgEnvelope(tool, unknown, declared, account, siblings = []) {
136
+ const names = unknown.map((u) => `"${u.sent}"`).join(', ');
137
+ const accepts = `This tool accepts: ${declared.join(', ')}.`;
138
+ const suggested = unknown.flatMap((u) => u.suggestions);
139
+ let hint;
140
+ if (suggested.length > 0) {
141
+ const did = unknown
142
+ .filter((u) => u.suggestions.length > 0)
143
+ .map((u) => u.suggestions.map((s) => `"${s}"`).join(' or '))
144
+ .join(', ');
145
+ hint = `Did you mean ${did}? ${accepts}`;
146
+ }
147
+ else if (siblings.length > 0) {
148
+ const spelled = siblings
149
+ .map((s) => `"${s.key}" (${s.tools.slice(0, 3).join(', ')})`)
150
+ .join(' and ');
151
+ hint = `${accepts} Other tools in this service spell a similar argument ${spelled}.`;
152
+ }
153
+ else {
154
+ hint = accepts;
155
+ }
156
+ return {
157
+ error: 'unknown_argument',
158
+ message: `${tool} does not accept ${names}. Nothing was sent to Google.`,
159
+ hint,
160
+ retriable: false,
161
+ ...(account !== undefined ? { account } : {}),
162
+ };
163
+ }
package/dist/auth.js CHANGED
@@ -1,12 +1,10 @@
1
- import { OAuth2Client } from 'googleapis-common';
2
- import http from 'node:http';
3
- import { URL } from 'node:url';
4
1
  import { randomBytes } from 'node:crypto';
5
2
  import { openUrl } from './open-url.js';
6
3
  import { ACCOUNTS, getAccountSet } from './accounts.js';
7
4
  import { ADMIN_SCOPES, BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from './scope-catalog.js';
8
5
  import { resolveMasterKey } from './master-key.js';
9
6
  import { writeToken } from './token-store.js';
7
+ import { buildConsentClient, openLoopbackConsent, TESTING_MODE_WARNING } from './oauth-consent.js';
10
8
  // Personal (non-Workspace) accounts 403 on admin scopes; ADMIN_SCOPES stays per-account opt-in, never granted by default.
11
9
  export const BASE_SCOPES = [
12
10
  'https://www.googleapis.com/auth/gmail.modify',
@@ -134,7 +132,11 @@ export async function runAuthFlow(args) {
134
132
  // Auto-provisions on a fresh install (env > keychain > file > generate);
135
133
  // resolves eagerly so a provisioning failure surfaces before the browser opens.
136
134
  resolveMasterKey();
137
- const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
135
+ // Shared ephemeral-port loopback flow (oauth-consent.ts): listen first, then
136
+ // build the auth URL from the assigned redirect. CLI gets a patient timeout;
137
+ // errors propagate to main()'s fatal handler like every other CLI failure.
138
+ const loop = await openLoopbackConsent({ timeoutMs: 10 * 60_000 });
139
+ const oauth2Client = buildConsentClient(loop.redirect);
138
140
  // CSRF protection for the OAuth callback (RFC 6749 §10.12).
139
141
  const expectedState = randomBytes(32).toString('hex');
140
142
  const authorizeUrl = oauth2Client.generateAuthUrl({
@@ -149,72 +151,13 @@ export async function runAuthFlow(args) {
149
151
  if (getAdminAccounts().includes(alias)) {
150
152
  console.log(' ⚠ Admin scopes included — this account will be granted Workspace admin access.');
151
153
  }
152
- console.log(`Opening browser for authorization...`);
153
- return new Promise((resolve, reject) => {
154
- const server = http
155
- .createServer(async (req, res) => {
156
- try {
157
- if (req.url && req.url.startsWith('/oauth2callback')) {
158
- const qs = new URL(req.url, 'http://localhost:4242').searchParams;
159
- const error = qs.get('error');
160
- if (error) {
161
- res.writeHead(400, { 'Content-Type': 'text/plain' });
162
- res.end(`Authorization denied: ${error}`);
163
- server.close();
164
- server.closeAllConnections();
165
- reject(new Error(`Authorization denied: ${error}`));
166
- return;
167
- }
168
- const code = qs.get('code');
169
- if (!code) {
170
- res.writeHead(400, { 'Content-Type': 'text/plain' });
171
- res.end('No authorization code received.');
172
- server.close();
173
- server.closeAllConnections();
174
- reject(new Error('No authorization code received'));
175
- return;
176
- }
177
- const returnedState = qs.get('state');
178
- if (returnedState !== expectedState) {
179
- res.writeHead(400, { 'Content-Type': 'text/plain' });
180
- res.end('State mismatch — possible CSRF attempt. Aborting.');
181
- server.close();
182
- server.closeAllConnections();
183
- reject(new Error('OAuth state token mismatch'));
184
- return;
185
- }
186
- const { tokens } = await oauth2Client.getToken(code);
187
- writeToken(alias, tokens);
188
- res.writeHead(200, { 'Content-Type': 'text/html' });
189
- res.end('<h2>Authentication successful!</h2><p>You can close this tab.</p>');
190
- server.close();
191
- server.closeAllConnections();
192
- console.log(`Token saved (encrypted) for ${alias}.`);
193
- console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
194
- resolve();
195
- }
196
- }
197
- catch (e) {
198
- res.writeHead(500, { 'Content-Type': 'text/plain' });
199
- res.end('Internal error during authentication.');
200
- server.close();
201
- server.closeAllConnections();
202
- reject(e);
203
- }
204
- })
205
- // Bind to loopback only — never expose the OAuth callback to the local network.
206
- .listen(4242, '127.0.0.1', () => {
207
- // Always print the URL first: the browser launch is best-effort and
208
- // silently does nothing on headless/SSH sessions.
209
- console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
210
- openUrl(authorizeUrl);
211
- });
212
- server.on('error', (err) => {
213
- if (err.code === 'EADDRINUSE') {
214
- console.error('Port 4242 is already in use. Close the process using it and retry.');
215
- process.exit(1);
216
- }
217
- reject(err);
218
- });
219
- });
154
+ // Always print the URL first: the browser launch is best-effort and
155
+ // silently does nothing on headless/SSH sessions.
156
+ console.log(`Opening your browser to authorize "${alias}". If nothing opens, visit:\n${authorizeUrl}`);
157
+ openUrl(authorizeUrl);
158
+ const tokens = await loop.finish(oauth2Client, expectedState);
159
+ writeToken(alias, tokens);
160
+ console.log(`Token saved (encrypted) for ${alias}.`);
161
+ console.log(TESTING_MODE_WARNING);
162
+ console.log('Next: authenticate your other aliases, then verify with: mcp-google-multi config check');
220
163
  }
@@ -0,0 +1,26 @@
1
+ export interface MtBootGates {
2
+ /** True = multi-tenant deployment: the server boots with zero accounts
3
+ * (tenants provision in-band) and the provisioning gate below replaces the
4
+ * flat owner-allowlist requirement. */
5
+ multiTenant: boolean;
6
+ /** Fail-fast replacement for the E_OWNER_EMAILS_REQUIRED check. MUST refuse
7
+ * (exit/throw) when no valid provisioning mechanism is configured — an
8
+ * ungated HTTP endpoint must never boot, whatever the condition checked. */
9
+ assertProvisioningGate: () => void;
10
+ }
11
+ export declare function setMtBootGates(g: MtBootGates | null): void;
12
+ export declare function mtBootGates(): MtBootGates | null;
13
+ export declare function isMultiTenantBoot(): boolean;
14
+ /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
15
+ * multi-tenant HTTP deploy starts empty by design. */
16
+ export declare function accountsAssertRequired(wantStdio: boolean, multiTenant: boolean): boolean;
17
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
18
+ * the env registry is process-wide, so it would graft one operator's accounts
19
+ * onto every tenant and turn every tenant's first wizard call into a dead
20
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
21
+ * of one clear one. */
22
+ export declare function assertNoEnvAccountsMode(env?: NodeJS.ProcessEnv): void;
23
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
24
+ * global scope profile, so on a multi-tenant box one operator's scope choice
25
+ * would silently apply to every tenant's consent. */
26
+ export declare function assertNoEnvOptionalScopesMode(env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,38 @@
1
+ // S1.16: multi-tenant boot gates. The free core never sets these — every
2
+ // helper reduces to today's single-owner behavior when no gates are
3
+ // installed. Deliberately a side-effect-free module (accounts.ts/auth.ts
4
+ // resolve env at import time) so the tenancy module can import it cheaply.
5
+ import { failStartup } from './config-file.js';
6
+ let gates = null;
7
+ export function setMtBootGates(g) {
8
+ gates = g;
9
+ }
10
+ export function mtBootGates() {
11
+ return gates;
12
+ }
13
+ export function isMultiTenantBoot() {
14
+ return gates?.multiTenant === true;
15
+ }
16
+ /** BR-4 applies to stdio always, and to HTTP only when single-owner: a
17
+ * multi-tenant HTTP deploy starts empty by design. */
18
+ export function accountsAssertRequired(wantStdio, multiTenant) {
19
+ return wantStdio || !multiTenant;
20
+ }
21
+ /** A set GOOGLE_ACCOUNTS on a multi-tenant boot is refused at DEPLOY time:
22
+ * the env registry is process-wide, so it would graft one operator's accounts
23
+ * onto every tenant and turn every tenant's first wizard call into a dead
24
+ * end (the per-call E_ENV_ACCOUNTS_MODE guard), N confusing failures instead
25
+ * of one clear one. */
26
+ export function assertNoEnvAccountsMode(env = process.env) {
27
+ if (env.GOOGLE_ACCOUNTS?.trim()) {
28
+ failStartup('E_ENV_ACCOUNTS_MODE_MT', 'GOOGLE_ACCOUNTS must not be set on a multi-tenant deployment; accounts are linked in-band per tenant. Unset it and restart.');
29
+ }
30
+ }
31
+ /** Same class, second vector: GOOGLE_OPTIONAL_SCOPES is a process-wide legacy
32
+ * global scope profile, so on a multi-tenant box one operator's scope choice
33
+ * would silently apply to every tenant's consent. */
34
+ export function assertNoEnvOptionalScopesMode(env = process.env) {
35
+ if (env.GOOGLE_OPTIONAL_SCOPES?.trim()) {
36
+ failStartup('E_ENV_OPTIONAL_SCOPES_MT', 'GOOGLE_OPTIONAL_SCOPES must not be set on a multi-tenant deployment; scopes are chosen per tenant at consent. Unset it and restart.');
37
+ }
38
+ }
package/dist/client.js CHANGED
@@ -2,22 +2,32 @@ import { OAuth2Client } from 'googleapis-common';
2
2
  import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
3
3
  import { readToken, updateToken } from './token-store.js';
4
4
  import { reauthHint } from './reauth-hint.js';
5
+ /** A local precondition failure, carrying a code the error mapper classifies
6
+ * on. Without it these land on the generic floor and read as Google errors. */
7
+ function tagged(message, code) {
8
+ return Object.assign(new Error(message), { code });
9
+ }
5
10
  export async function getClient(account) {
6
11
  // BR-7: lazy cross-process reload — one stat per dispatch, no watcher;
7
12
  // reload failures keep the last-good registry, never kill the server.
8
13
  refreshAccountSetIfStale();
9
14
  const config = getAccountSet().configs[account];
10
15
  if (!config) {
11
- throw new Error(`Unknown account "${account}". Valid aliases: ${getAccountSet().aliases.join(', ')}`);
16
+ // Tagged so mapGoogleError can classify it. Untagged, a caller's typo in
17
+ // an alias came back as `upstream_error` with "Unclassified error", i.e.
18
+ // the server blaming Google for a local argument mistake.
19
+ throw tagged(`Unknown account "${account}". Valid aliases: ${getAccountSet().aliases.join(', ')}`, 'E_UNKNOWN_ACCOUNT');
12
20
  }
13
21
  if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
14
- throw new Error('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
15
- 'Check that .env exists in the project root or pass them as env vars.');
22
+ throw tagged('GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set. ' +
23
+ 'Check that .env exists in the project root or pass them as env vars.', 'E_NO_OAUTH_CLIENT');
16
24
  }
17
- const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost:4242/oauth2callback');
25
+ // Redirect URI is unused on the refresh-token grant; consent flows bind an
26
+ // ephemeral loopback port at auth time (oauth-consent.ts).
27
+ const oauth2Client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, 'http://localhost/oauth2callback');
18
28
  const tokenData = readToken(account);
19
29
  if (!tokenData) {
20
- throw new Error(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`);
30
+ throw tagged(`No token found for account "${account}" (${config.email}). ${reauthHint(account)}`, 'E_NO_TOKEN');
21
31
  }
22
32
  oauth2Client.setCredentials(tokenData);
23
33
  oauth2Client.on('tokens', (tokens) => {
@@ -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, {
@@ -17,6 +30,7 @@ export interface ConfigFile {
17
30
  defaultAccount?: string;
18
31
  discovery?: 'lazy' | 'curated' | 'eager';
19
32
  toolsets?: string;
33
+ usageMetrics?: boolean;
20
34
  }
21
35
  export declare function failStartup(slug: string, message: string): never;
22
36
  export declare class ConfigFileError extends Error {
@@ -10,6 +10,38 @@ export function configDir(env = process.env) {
10
10
  export function configFilePath(env = process.env) {
11
11
  return path.join(configDir(env), 'config.json');
12
12
  }
13
+ export function tenantsDir(env = process.env) {
14
+ return path.join(configDir(env), 'tenants');
15
+ }
16
+ // A tenant id becomes a filesystem path segment exactly like an account
17
+ // alias, so it rides the same traversal guard.
18
+ function assertTenantId(tenantId) {
19
+ if (!ALIAS_RE.test(tenantId) || isReservedAlias(tenantId)) {
20
+ throw new Error(`E_TENANT_ID_INVALID: invalid tenant id "${tenantId}".`);
21
+ }
22
+ }
23
+ export function tenantConfigFilePath(tenantId, env = process.env) {
24
+ assertTenantId(tenantId);
25
+ return path.join(tenantsDir(env), tenantId, 'config.json');
26
+ }
27
+ export function tenantTokenDir(tenantId, env = process.env) {
28
+ assertTenantId(tenantId);
29
+ return path.join(tenantsDir(env), tenantId, 'tokens');
30
+ }
31
+ /** Create and permission-harden the tenant directory chain. mkdirSync's
32
+ * recursive mode only stamps the DEEPEST newly-created dir, so every level
33
+ * gets an explicit chmod: the shared tenants/ root (created by whichever
34
+ * tenant provisions first) must never stay group-readable. */
35
+ export function ensureTenantDirs(tenantId, env = process.env) {
36
+ const tokenDir = tenantTokenDir(tenantId, env);
37
+ const levels = [configDir(env), tenantsDir(env), path.join(tenantsDir(env), tenantId), tokenDir];
38
+ for (const dir of levels) {
39
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
40
+ if (process.platform !== 'win32')
41
+ fs.chmodSync(dir, 0o700);
42
+ }
43
+ return { configFile: tenantConfigFilePath(tenantId, env), tokenDir };
44
+ }
13
45
  // Alias charset is load-bearing security, not cosmetics: the alias becomes a
14
46
  // tokenDir path segment (encPath), so this guard blocks path traversal.
15
47
  export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
@@ -22,7 +54,10 @@ const accountEntrySchema = z.strictObject({
22
54
  // masterKey, jwtKey, clientId) fails validation, structurally enforcing
23
55
  // secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
24
56
  // of the frozen envelope; their consumers land in later slices.
25
- const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
57
+ export const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
58
+ export function isReservedAlias(name) {
59
+ return RESERVED_ALIASES.includes(name);
60
+ }
26
61
  const configSchema = z.strictObject({
27
62
  version: z.number().int(),
28
63
  accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
@@ -36,6 +71,10 @@ const configSchema = z.strictObject({
36
71
  defaultAccount: z.string().optional(),
37
72
  discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
38
73
  toolsets: z.string().optional(),
74
+ // Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
75
+ // a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
76
+ // (strictObject); remove the key first.
77
+ usageMetrics: z.boolean().optional(),
39
78
  });
40
79
  export function failStartup(slug, message) {
41
80
  process.stderr.write(`${slug}: ${message}\n`);
package/dist/discover.js CHANGED
@@ -1,8 +1,32 @@
1
1
  import { z } from 'zod';
2
2
  import { describePolicy } from './write-control.js';
3
+ // The description budget is the whole point of lazy mode: a full op list per
4
+ // service put lazy tools/list at ~11.5k tokens (RQ2), so descriptions carry a
5
+ // CAPPED vocabulary and the complete catalog stays in this tool's RESULT.
6
+ const CURATED_OPS_CAP = 10;
7
+ const GENERATED_GROUPS_CAP = 6;
3
8
  function opVocabulary(registry, service) {
4
- const ops = registry.catalog(service).map((o) => o.tool.startsWith(`${service}_`) ? o.tool.slice(service.length + 1) : o.tool);
5
- return [...new Set(ops)].join(', ');
9
+ const { curated, generated } = registry.opNames(service);
10
+ const parts = [];
11
+ if (curated.length > 0) {
12
+ const shown = curated.slice(0, CURATED_OPS_CAP);
13
+ const more = curated.length - shown.length;
14
+ parts.push(`${shown.join(', ')}${more > 0 ? ` +${more} more` : ''}`);
15
+ }
16
+ if (generated.length > 0) {
17
+ // The generated long tail compresses to its resource groups: denser and
18
+ // more selective than any truncated name list.
19
+ const counts = new Map();
20
+ for (const op of generated) {
21
+ const group = op.split('_')[0];
22
+ counts.set(group, (counts.get(group) ?? 0) + 1);
23
+ }
24
+ const groups = [...counts.entries()].sort((a, b) => b[1] - a[1]);
25
+ const shown = groups.slice(0, GENERATED_GROUPS_CAP).map(([g]) => g);
26
+ const more = groups.length - shown.length;
27
+ parts.push(`${generated.length} generated ops: ${shown.join(', ')}${more > 0 ? ` +${more} areas` : ''}`);
28
+ }
29
+ return parts.join('; ');
6
30
  }
7
31
  export function registerDiscoverTools(registry, policy) {
8
32
  const registerMeta = registry.registerMeta;
@@ -11,9 +35,8 @@ export function registerDiscoverTools(registry, policy) {
11
35
  // name/schema context budget after. stdio-only semantics — over stateless
12
36
  // HTTP the mode is forced curated and these are no-ops.
13
37
  registerMeta('discover_all', {
14
- description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). ' +
15
- 'Use when starting substantial Google work so the shaped, high-quality tools are ' +
16
- 'directly callable; prefer them over google_api_call. Pair with discover_reset when done.',
38
+ description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). Use when ' +
39
+ 'starting substantial Google work; prefer these over google_api_call. Pair with discover_reset.',
17
40
  inputSchema: {},
18
41
  _meta: { 'anthropic/alwaysLoad': true },
19
42
  }, async () => {
@@ -35,9 +58,8 @@ export function registerDiscoverTools(registry, policy) {
35
58
  };
36
59
  });
37
60
  registerMeta('discover_reset', {
38
- description: 'Collapse the tool surface back to the configured default (lean meta-tools-only under the ' +
39
- 'default lazy mode), reclaiming context budget after heavy Google work. All tools remain ' +
40
- 'callable by name after collapsing.',
61
+ description: 'Collapse the tool surface back to the configured default, reclaiming context budget ' +
62
+ 'after heavy Google work. All tools remain callable by name after collapsing.',
41
63
  inputSchema: {},
42
64
  _meta: { 'anthropic/alwaysLoad': true },
43
65
  }, async () => {
@@ -58,13 +80,12 @@ export function registerDiscoverTools(registry, policy) {
58
80
  });
59
81
  for (const service of registry.services()) {
60
82
  registerMeta(`${service}_discover`, {
61
- description: `List the available ${service} operations. ` +
62
- (registry.mode === 'lazy'
63
- ? `Operational ${service} tools are hidden until discovered — call this first, then call the tool you need by name. `
64
- : `Returns the ${service} catalog (and reveals any still-hidden ${service} tools). `) +
65
- `Operations: ${opVocabulary(registry, service)}.`,
83
+ description: (registry.mode === 'lazy'
84
+ ? `Discover ${service}: lists the catalog and reveals its hidden tools; call first, then call the tool by name. `
85
+ : `List the ${service} catalog (reveals any still-hidden ${service} tools). `) +
86
+ `Ops: ${opVocabulary(registry, service)}.`,
66
87
  inputSchema: {
67
- query: z.string().optional().describe('Keyword to filter the returned operations'),
88
+ query: z.string().optional().describe('Filter keyword'),
68
89
  },
69
90
  _meta: { 'anthropic/alwaysLoad': true },
70
91
  }, async ({ query }) => {
@@ -1,7 +1,14 @@
1
- export declare const WORKSPACE_APIS: Record<string, {
1
+ export declare const SUPPORTED_APIS: Record<string, {
2
2
  id: string;
3
3
  version: string;
4
4
  }>;
5
+ export declare const API_ALIASES: Record<string, string[]>;
6
+ /**
7
+ * Resolve an `api` argument to real SUPPORTED_APIS keys: exact match, then
8
+ * case/punctuation-normalized ("Search-Console" -> searchconsole), then the
9
+ * alias map. null = genuinely unknown.
10
+ */
11
+ export declare function resolveApiAliases(api: string): string[] | null;
5
12
  export interface DiscoveryParam {
6
13
  location: 'path' | 'query';
7
14
  required?: boolean;