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.
- package/README.md +6 -4
- package/dist/accounts.d.ts +9 -1
- package/dist/accounts.js +24 -12
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +76 -0
- package/dist/arg-normalize.js +314 -0
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/boot-gates.d.ts +26 -0
- package/dist/boot-gates.js +38 -0
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +14 -0
- package/dist/config-file.js +40 -1
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +96 -17
- package/dist/doctor.d.ts +21 -1
- package/dist/doctor.js +127 -20
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.d.ts +1 -1
- package/dist/fanout.js +49 -11
- package/dist/http-transport.d.ts +27 -3
- package/dist/http-transport.js +52 -12
- package/dist/identity.d.ts +3 -1
- package/dist/index.js +145 -23
- package/dist/master-key.js +11 -5
- package/dist/mcp-token.d.ts +17 -6
- package/dist/mcp-token.js +8 -6
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/oauth-as.d.ts +31 -0
- package/dist/oauth-as.js +64 -10
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/registry-transfer.js +1 -2
- package/dist/registry.d.ts +56 -2
- package/dist/registry.js +205 -15
- package/dist/scope-catalog.d.ts +1 -0
- package/dist/scope-catalog.js +17 -1
- package/dist/scope-observability.js +2 -1
- package/dist/services.d.ts +8 -0
- package/dist/services.js +28 -1
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/token-store.d.ts +3 -2
- package/dist/token-store.js +0 -0
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +31 -3
- package/dist/tools/_errors.js +307 -43
- package/dist/tools/_local-files.js +7 -2
- package/dist/tools/account-wizard.d.ts +24 -1
- package/dist/tools/account-wizard.js +152 -58
- package/dist/tools/accounts-tool.js +3 -2
- package/dist/tools/admin.js +7 -6
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +284 -0
- package/dist/tools/calendar.d.ts +7 -0
- package/dist/tools/calendar.js +75 -15
- package/dist/tools/chat.js +7 -4
- package/dist/tools/contacts.js +57 -30
- package/dist/tools/docs.js +56 -54
- package/dist/tools/drive.d.ts +2 -0
- package/dist/tools/drive.js +200 -91
- package/dist/tools/forms.js +7 -7
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +345 -214
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.js +4 -4
- package/dist/tools/generated/calendar.js +28 -28
- package/dist/tools/generated/chat.js +83 -56
- package/dist/tools/generated/classroom.js +258 -216
- package/dist/tools/generated/cloudidentity.js +76 -68
- package/dist/tools/generated/cloudsearch.js +40 -39
- package/dist/tools/generated/contacts.js +20 -11
- package/dist/tools/generated/drive.js +62 -53
- package/dist/tools/generated/drivelabels.js +60 -32
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.js +136 -109
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +8 -7
- package/dist/tools/generated/licensing.js +39 -23
- package/dist/tools/generated/meet.js +14 -14
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.js +3 -3
- package/dist/tools/generated/reseller.js +34 -28
- package/dist/tools/generated/script.js +29 -19
- package/dist/tools/generated/searchconsole.js +5 -3
- package/dist/tools/generated/sheets.js +10 -9
- package/dist/tools/generated/tasks.js +10 -4
- package/dist/tools/generated/vault.js +65 -56
- package/dist/tools/generated/workspaceevents.js +17 -16
- package/dist/tools/gmail.d.ts +10 -1
- package/dist/tools/gmail.js +50 -18
- package/dist/tools/google-api.d.ts +4 -1
- package/dist/tools/google-api.js +73 -19
- package/dist/tools/meet.js +5 -2
- package/dist/tools/searchconsole.js +10 -10
- package/dist/tools/sheets.js +36 -48
- package/dist/tools/slides.js +6 -6
- package/dist/tools/tasks.js +16 -16
- package/dist/trim.d.ts +21 -0
- package/dist/trim.js +47 -0
- package/dist/usage-metrics.d.ts +161 -0
- package/dist/usage-metrics.js +609 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- 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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) => {
|
package/dist/config-file.d.ts
CHANGED
|
@@ -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 {
|
package/dist/config-file.js
CHANGED
|
@@ -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
|
|
5
|
-
|
|
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
|
-
'
|
|
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
|
|
39
|
-
'
|
|
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:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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('
|
|
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
|
|
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;
|