mcp-google-multi 6.0.0-beta.1 → 6.0.0-beta.2
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 +5 -3
- package/dist/accounts.d.ts +5 -0
- package/dist/accounts.js +12 -1
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +61 -4
- package/dist/arg-normalize.js +228 -4
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- 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 +9 -1
- package/dist/doctor.js +32 -4
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.js +43 -8
- package/dist/http-transport.d.ts +11 -2
- package/dist/http-transport.js +10 -5
- package/dist/index.js +112 -12
- 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-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.d.ts +49 -2
- package/dist/registry.js +147 -7
- package/dist/scope-catalog.js +16 -0
- 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/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 -51
- 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.js +132 -70
- 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.js +48 -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 +155 -0
- package/dist/usage-metrics.js +603 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- package/package.json +8 -3
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
|
}
|
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
|
@@ -17,6 +17,7 @@ export interface ConfigFile {
|
|
|
17
17
|
defaultAccount?: string;
|
|
18
18
|
discovery?: 'lazy' | 'curated' | 'eager';
|
|
19
19
|
toolsets?: string;
|
|
20
|
+
usageMetrics?: boolean;
|
|
20
21
|
}
|
|
21
22
|
export declare function failStartup(slug: string, message: string): never;
|
|
22
23
|
export declare class ConfigFileError extends Error {
|
package/dist/config-file.js
CHANGED
|
@@ -36,6 +36,10 @@ const configSchema = z.strictObject({
|
|
|
36
36
|
defaultAccount: z.string().optional(),
|
|
37
37
|
discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
|
|
38
38
|
toolsets: z.string().optional(),
|
|
39
|
+
// Local usage metrics (metrics-feature-spec): absent = off. Downgrade rule:
|
|
40
|
+
// a pre-6.0 build reading a config carrying this key fails E_CONFIG_INVALID
|
|
41
|
+
// (strictObject); remove the key first.
|
|
42
|
+
usageMetrics: z.boolean().optional(),
|
|
39
43
|
});
|
|
40
44
|
export function failStartup(slug, message) {
|
|
41
45
|
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;
|
package/dist/discovery-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
|
-
export const
|
|
4
|
+
export const SUPPORTED_APIS = {
|
|
5
5
|
gmail: { id: 'gmail', version: 'v1' },
|
|
6
6
|
drive: { id: 'drive', version: 'v3' },
|
|
7
7
|
calendar: { id: 'calendar', version: 'v3' },
|
|
@@ -20,6 +20,8 @@ export const WORKSPACE_APIS = {
|
|
|
20
20
|
admin_reports: { id: 'admin', version: 'reports_v1' },
|
|
21
21
|
admin_datatransfer: { id: 'admin', version: 'datatransfer_v1' },
|
|
22
22
|
groupssettings: { id: 'groupssettings', version: 'v1' },
|
|
23
|
+
analyticsadmin: { id: 'analyticsadmin', version: 'v1beta' },
|
|
24
|
+
analyticsdata: { id: 'analyticsdata', version: 'v1beta' },
|
|
23
25
|
appsmarket: { id: 'appsmarket', version: 'v2' },
|
|
24
26
|
classroom: { id: 'classroom', version: 'v1' },
|
|
25
27
|
cloudidentity: { id: 'cloudidentity', version: 'v1' },
|
|
@@ -33,6 +35,38 @@ export const WORKSPACE_APIS = {
|
|
|
33
35
|
vault: { id: 'vault', version: 'v1' },
|
|
34
36
|
workspaceevents: { id: 'workspaceevents', version: 'v1' },
|
|
35
37
|
};
|
|
38
|
+
// Names agents actually type for an API, mapped to real SUPPORTED_APIS keys.
|
|
39
|
+
// Motivated by observed escape-hatch misses ("analytics" is two Discovery
|
|
40
|
+
// APIs, the Admin SDK is three); keep entries plural-target only when the
|
|
41
|
+
// split is real.
|
|
42
|
+
export const API_ALIASES = {
|
|
43
|
+
analytics: ['analyticsadmin', 'analyticsdata'],
|
|
44
|
+
ga4: ['analyticsadmin', 'analyticsdata'],
|
|
45
|
+
googleanalytics: ['analyticsadmin', 'analyticsdata'],
|
|
46
|
+
admin: ['admin_directory', 'admin_reports', 'admin_datatransfer'],
|
|
47
|
+
adminsdk: ['admin_directory', 'admin_reports', 'admin_datatransfer'],
|
|
48
|
+
directory: ['admin_directory'],
|
|
49
|
+
webmasters: ['searchconsole'],
|
|
50
|
+
gsc: ['searchconsole'],
|
|
51
|
+
contacts: ['people'],
|
|
52
|
+
appsscript: ['script'],
|
|
53
|
+
appscript: ['script'],
|
|
54
|
+
gmailpostmastertools: ['postmaster'],
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Resolve an `api` argument to real SUPPORTED_APIS keys: exact match, then
|
|
58
|
+
* case/punctuation-normalized ("Search-Console" -> searchconsole), then the
|
|
59
|
+
* alias map. null = genuinely unknown.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveApiAliases(api) {
|
|
62
|
+
if (SUPPORTED_APIS[api])
|
|
63
|
+
return [api];
|
|
64
|
+
const norm = api.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
65
|
+
const direct = Object.keys(SUPPORTED_APIS).find((k) => k.replace(/[^a-z0-9]/g, '') === norm);
|
|
66
|
+
if (direct)
|
|
67
|
+
return [direct];
|
|
68
|
+
return API_ALIASES[norm] ?? null;
|
|
69
|
+
}
|
|
36
70
|
const TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
37
71
|
const STALE_RETRY_MS = 5 * 60 * 1000;
|
|
38
72
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
@@ -96,9 +130,9 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
96
130
|
const cached = memoryCache.get(api);
|
|
97
131
|
if (cached && now() < cached.refreshAfter)
|
|
98
132
|
return cached.index;
|
|
99
|
-
const spec =
|
|
133
|
+
const spec = SUPPORTED_APIS[api];
|
|
100
134
|
if (!spec) {
|
|
101
|
-
throw new Error(`Unknown api "${api}". Known: ${Object.keys(
|
|
135
|
+
throw new Error(`Unknown api "${api}". Known: ${Object.keys(SUPPORTED_APIS).join(', ')}`);
|
|
102
136
|
}
|
|
103
137
|
const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
|
|
104
138
|
const cacheDir = deps.cacheDir ?? discoveryCacheDir();
|
|
@@ -124,20 +158,34 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
124
158
|
if (cacheFresh())
|
|
125
159
|
doc = readCache();
|
|
126
160
|
if (!doc) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
161
|
+
// Newer APIs (analyticsadmin/analyticsdata) are absent from the central
|
|
162
|
+
// discovery directory (404); each service's own $discovery endpoint is
|
|
163
|
+
// authoritative, so try both — same fallback as scripts/fetch-discovery.
|
|
164
|
+
const urls = [
|
|
165
|
+
`https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`,
|
|
166
|
+
`https://${spec.id}.googleapis.com/$discovery/rest?version=${spec.version}`,
|
|
167
|
+
];
|
|
168
|
+
let fetchError;
|
|
169
|
+
for (const url of urls) {
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetchFn(url);
|
|
172
|
+
if (!res.ok)
|
|
173
|
+
throw new Error(`HTTP ${res.status}`);
|
|
174
|
+
doc = (await res.json());
|
|
175
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
176
|
+
fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
fetchError = err;
|
|
181
|
+
doc = undefined;
|
|
182
|
+
}
|
|
135
183
|
}
|
|
136
|
-
|
|
184
|
+
if (!doc) {
|
|
137
185
|
doc = readCache();
|
|
138
186
|
staleFallback = true;
|
|
139
187
|
if (!doc) {
|
|
140
|
-
throw new Error(`Could not fetch the Google API Discovery document for "${api}" and no local cache exists (${
|
|
188
|
+
throw new Error(`Could not fetch the Google API Discovery document for "${api}" and no local cache exists (${fetchError?.message ?? 'fetch failed'}). Retry when online.`, { cause: fetchError });
|
|
141
189
|
}
|
|
142
190
|
}
|
|
143
191
|
}
|
|
@@ -148,9 +196,24 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
148
196
|
export function clearDiscoveryMemoryCache() {
|
|
149
197
|
memoryCache.clear();
|
|
150
198
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
199
|
+
// GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
|
|
200
|
+
// and check* predicates are POSTs purely for the request-body size — reads.
|
|
201
|
+
const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|suggest|(batch)?run\w*report)/i;
|
|
202
|
+
// `cancelWipe` must be matched here, BEFORE `cancel` reaches the delete list:
|
|
203
|
+
// it calls off a pending wipe, which is the opposite of one. Update is tested
|
|
204
|
+
// before delete for exactly that reason.
|
|
205
|
+
// State transitions on something that already exists: reversible, and none of
|
|
206
|
+
// them creates anything, so `create` was both wrong and the most permissive
|
|
207
|
+
// class available. `(batch)?` mirrors the delete list, without which every
|
|
208
|
+
// `batchUpdate*` POST fell through to `create`.
|
|
209
|
+
const POST_UPDATE_VERB = /^(cancelWipe|(batch)?(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format|change|close|reopen|enable|disable|hide|unhide|accept|approve|decline|reassign|reactivate|renew|mark|turn|suspend|activate|make|return|reclaim|complete))/i;
|
|
210
|
+
// archive sits with the deletes: in GA4 archiving a custom dimension/metric is
|
|
211
|
+
// permanent, so the most restrictive write class is the safe classification.
|
|
212
|
+
// So do the teardown verbs: `stop` and `cancel` tear down a push channel or a
|
|
213
|
+
// long-running operation, `wipe`/`invalidate`/`signOut` destroy device data,
|
|
214
|
+
// codes and sessions, `reset` discards a configuration, and `end` terminates a
|
|
215
|
+
// live conference. Each removes something that existed.
|
|
216
|
+
const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive|wipe|invalidate|signOut|stop|cancel|unregister|unreserve|reset|end)/i;
|
|
154
217
|
export function cudFromMethod(method) {
|
|
155
218
|
switch (method.httpMethod) {
|
|
156
219
|
case 'GET':
|
|
@@ -181,7 +244,23 @@ export function expandPath(template, pathParams) {
|
|
|
181
244
|
if (value === undefined) {
|
|
182
245
|
throw new Error(`Missing required path parameter "${name}"`);
|
|
183
246
|
}
|
|
184
|
-
|
|
247
|
+
// A blank segment collapses the path and Google routes the resulting
|
|
248
|
+
// trailing-slash URL to the COLLECTION: `sites.get` with siteUrl:"" came
|
|
249
|
+
// back as the whole site LIST, reported as success. Dot segments resolve
|
|
250
|
+
// at URL-parse time, so `../v1/contactGroups` retargets the call at a
|
|
251
|
+
// different endpoint and returns ITS response as success. Both are
|
|
252
|
+
// silent-wrong, so refuse locally rather than send a different request.
|
|
253
|
+
// `{+param}` keeps its slashes, so every segment has to be checked.
|
|
254
|
+
const segments = plus ? String(value).split('/') : [String(value)];
|
|
255
|
+
for (const segment of segments) {
|
|
256
|
+
if (segment.trim() === '') {
|
|
257
|
+
throw new Error(`Path parameter "${name}" is empty. An empty value addresses the collection, not one resource, so nothing was sent to Google.`);
|
|
258
|
+
}
|
|
259
|
+
if (segment === '.' || segment === '..') {
|
|
260
|
+
throw new Error(`Path parameter "${name}" contains a "${segment}" path segment, which would retarget the request at a different endpoint. Nothing was sent to Google.`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return segments.map(encodeURIComponent).join('/');
|
|
185
264
|
});
|
|
186
265
|
}
|
|
187
266
|
export function searchMethods(index, query, limit = 10) {
|
package/dist/doctor.d.ts
CHANGED
|
@@ -53,6 +53,9 @@ export interface HttpProbeResult {
|
|
|
53
53
|
}
|
|
54
54
|
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
55
55
|
export declare function apiEnableLink(api: string): string;
|
|
56
|
+
/** One line, state AND source, so the people being measured can see both
|
|
57
|
+
* here and in `diagnose` (metrics spec section 2). Read-only. */
|
|
58
|
+
export declare function usageMetricsStatusLine(env: Record<string, string | undefined>): string;
|
|
56
59
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
57
60
|
export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
|
|
58
61
|
export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
|
|
@@ -64,7 +67,12 @@ export declare function renderReport(report: DiagnosticsReport, version: string)
|
|
|
64
67
|
/** Exit non-zero when any section FAILs; with strict, WARN counts too. */
|
|
65
68
|
export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
|
|
66
69
|
/** Agent-callable structured health report (read-only). Mirrors `doctor`'s
|
|
67
|
-
* engine
|
|
70
|
+
* engine. Registered as a META tool, like `account_list`: it introspects this
|
|
71
|
+
* server rather than Google data, and as a normal tool it became a service of
|
|
72
|
+
* its own with no `{service}_discover` to reveal it (discover tools are built
|
|
73
|
+
* from already-registered services, and this runs after that), so it was
|
|
74
|
+
* advertised only once something else expanded the surface. The README sends
|
|
75
|
+
* people here when they are stuck, so it has to be findable. */
|
|
68
76
|
export declare function registerDiagnoseTool(registry: ToolRegistry): void;
|
|
69
77
|
export interface ResetOptions {
|
|
70
78
|
account?: string;
|
package/dist/doctor.js
CHANGED
|
@@ -5,8 +5,11 @@ import { getAccountSet } from './accounts.js';
|
|
|
5
5
|
import { deriveAccountHealth } from './tools/accounts-tool.js';
|
|
6
6
|
import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
|
|
7
7
|
import { hasToken } from './token-store.js';
|
|
8
|
-
import { configDir } from './config-file.js';
|
|
8
|
+
import { configDir, loadConfigFile } from './config-file.js';
|
|
9
|
+
import { envValueSource } from './env-load.js';
|
|
10
|
+
import { describeMetricsDir, resolveUsageMetrics, sourceLabel } from './usage-metrics.js';
|
|
9
11
|
import { probeApiEnablement } from './api-probe.js';
|
|
12
|
+
import { safeMessage, stringifyEnvelope } from './tools/_errors.js';
|
|
10
13
|
import { resolveHttpConfig, HttpConfigError } from './http-config.js';
|
|
11
14
|
import { parseOwnerEmails } from './http-transport.js';
|
|
12
15
|
const MIN_NODE_MAJOR = 22;
|
|
@@ -114,8 +117,24 @@ function sectionConfig(deps, set) {
|
|
|
114
117
|
hint = (hint ? `${hint} ` : '') + `Move it: \`mv ${envFile} ${target}\`.`;
|
|
115
118
|
}
|
|
116
119
|
}
|
|
120
|
+
lines.push(usageMetricsStatusLine(deps.env));
|
|
117
121
|
return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
|
|
118
122
|
}
|
|
123
|
+
/** One line, state AND source, so the people being measured can see both
|
|
124
|
+
* here and in `diagnose` (metrics spec section 2). Read-only. */
|
|
125
|
+
export function usageMetricsStatusLine(env) {
|
|
126
|
+
const envSrc = envValueSource('GOOGLE_USAGE_METRICS');
|
|
127
|
+
let configValue;
|
|
128
|
+
try {
|
|
129
|
+
configValue = loadConfigFile(undefined, 'throw')?.usageMetrics;
|
|
130
|
+
}
|
|
131
|
+
catch { /* invalid config is section 2's business, not this line's */ }
|
|
132
|
+
const state = resolveUsageMetrics(env, configValue, envSrc?.kind === 'file' ? envSrc.file : undefined);
|
|
133
|
+
if (!state.enabled)
|
|
134
|
+
return `local usage metrics: off ${sourceLabel(state.source)}`;
|
|
135
|
+
const d = describeMetricsDir(env);
|
|
136
|
+
return `local usage metrics: on ${sourceLabel(state.source)} -> ${d.dir} (${d.files} files, ${d.kb} KB)`;
|
|
137
|
+
}
|
|
119
138
|
function sectionKeys(deps, aliases) {
|
|
120
139
|
const provenance = deps.masterKeyProvenance();
|
|
121
140
|
const tokensExist = deps.anyTokensExist(aliases);
|
|
@@ -338,9 +357,14 @@ export function exitCodeFor(report, strict) {
|
|
|
338
357
|
return 0;
|
|
339
358
|
}
|
|
340
359
|
/** Agent-callable structured health report (read-only). Mirrors `doctor`'s
|
|
341
|
-
* engine
|
|
360
|
+
* engine. Registered as a META tool, like `account_list`: it introspects this
|
|
361
|
+
* server rather than Google data, and as a normal tool it became a service of
|
|
362
|
+
* its own with no `{service}_discover` to reveal it (discover tools are built
|
|
363
|
+
* from already-registered services, and this runs after that), so it was
|
|
364
|
+
* advertised only once something else expanded the surface. The README sends
|
|
365
|
+
* people here when they are stuck, so it has to be findable. */
|
|
342
366
|
export function registerDiagnoseTool(registry) {
|
|
343
|
-
registry.
|
|
367
|
+
registry.registerMeta('diagnose', {
|
|
344
368
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
345
369
|
description: 'Health report for this server: runtime, config, keys, per-account token status, scope grants, and API enablement. Read-only; returns copy-pasteable fixes for anything wrong. Call this to diagnose auth/config failures.',
|
|
346
370
|
inputSchema: {},
|
|
@@ -350,7 +374,11 @@ export function registerDiagnoseTool(registry) {
|
|
|
350
374
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
351
375
|
}
|
|
352
376
|
catch (e) {
|
|
353
|
-
return { content: [{ type: 'text', text:
|
|
377
|
+
return { content: [{ type: 'text', text: stringifyEnvelope({
|
|
378
|
+
error: 'diagnose_failed',
|
|
379
|
+
message: safeMessage(e),
|
|
380
|
+
retriable: false,
|
|
381
|
+
}) }], isError: true };
|
|
354
382
|
}
|
|
355
383
|
});
|
|
356
384
|
}
|
package/dist/env-load.d.ts
CHANGED
|
@@ -9,3 +9,14 @@ export interface EnvLoadPaths {
|
|
|
9
9
|
configDir?: string;
|
|
10
10
|
}
|
|
11
11
|
export declare function loadEnvFiles(paths?: EnvLoadPaths): EnvLoadResult;
|
|
12
|
+
/**
|
|
13
|
+
* Which layer supplied `key`: the real process env, or the first loaded .env
|
|
14
|
+
* file defining it (autoload never overwrites, so first wins). undefined when
|
|
15
|
+
* the key is unset. Powers self-announcing enablement sources (usage metrics).
|
|
16
|
+
*/
|
|
17
|
+
export declare function envValueSource(key: string): {
|
|
18
|
+
kind: 'process';
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'file';
|
|
21
|
+
file: string;
|
|
22
|
+
} | undefined;
|
package/dist/env-load.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
@@ -48,5 +48,27 @@ export function loadEnvFiles(paths = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
for (const [k, v] of boot)
|
|
50
50
|
process.env[k] = v;
|
|
51
|
+
lastLoad = { bootKeys: new Set(boot.keys()), loaded };
|
|
51
52
|
return { loaded, searched: candidates };
|
|
52
53
|
}
|
|
54
|
+
let lastLoad;
|
|
55
|
+
/**
|
|
56
|
+
* Which layer supplied `key`: the real process env, or the first loaded .env
|
|
57
|
+
* file defining it (autoload never overwrites, so first wins). undefined when
|
|
58
|
+
* the key is unset. Powers self-announcing enablement sources (usage metrics).
|
|
59
|
+
*/
|
|
60
|
+
export function envValueSource(key) {
|
|
61
|
+
if (process.env[key] === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (!lastLoad || lastLoad.bootKeys.has(key))
|
|
64
|
+
return { kind: 'process' };
|
|
65
|
+
const re = new RegExp(`^\\s*${key}\\s*=`, 'm');
|
|
66
|
+
for (const p of lastLoad.loaded) {
|
|
67
|
+
try {
|
|
68
|
+
if (re.test(readFileSync(p, 'utf-8')))
|
|
69
|
+
return { kind: 'file', file: p };
|
|
70
|
+
}
|
|
71
|
+
catch { /* file vanished since load: fall through */ }
|
|
72
|
+
}
|
|
73
|
+
return { kind: 'process' };
|
|
74
|
+
}
|
package/dist/executor.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getClient } from './client.js';
|
|
2
|
+
import { sliceEncoded } from './trim.js';
|
|
2
3
|
import { expandPath, isGoogleApiUrl } from './discovery-client.js';
|
|
3
4
|
import { handleGoogleApiError } from './tools/_errors.js';
|
|
4
5
|
import { scopeHintForMethod } from './scope-observability.js';
|
|
6
|
+
import { checkOutboundForMethod } from './outbound-allowlist.js';
|
|
5
7
|
export const MAX_RESPONSE_CHARS = 100_000;
|
|
6
8
|
export function jsonResult(payload, isError = false) {
|
|
7
9
|
const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
@@ -29,6 +31,11 @@ export function resolveRequestBody(httpMethod, body) {
|
|
|
29
31
|
return body ?? undefined;
|
|
30
32
|
}
|
|
31
33
|
export async function executeApiMethod(method, args, deps = {}) {
|
|
34
|
+
// Outbound allowlist: no-op when off; with it active, structured bodies are
|
|
35
|
+
// inspected for recipient fields and raw-compose methods are refused.
|
|
36
|
+
const outbound = checkOutboundForMethod(method.id, args.body, args.account);
|
|
37
|
+
if (outbound)
|
|
38
|
+
return outbound;
|
|
32
39
|
if (args.queryParams?.alt === 'media') {
|
|
33
40
|
return jsonResult({
|
|
34
41
|
error: 'binary_unsupported',
|
|
@@ -82,10 +89,14 @@ export async function executeApiMethod(method, args, deps = {}) {
|
|
|
82
89
|
});
|
|
83
90
|
const text = JSON.stringify(res.data ?? null);
|
|
84
91
|
if (text.length > MAX_RESPONSE_CHARS) {
|
|
92
|
+
// Budget the ENCODED head: jsonResult re-stringifies it, so every quote
|
|
93
|
+
// and backslash inside doubles. Slicing raw characters made the emitted
|
|
94
|
+
// result exceed the cap this tool advertises, by up to 15 percent on a
|
|
95
|
+
// quote-dense listing.
|
|
85
96
|
return jsonResult({
|
|
86
97
|
truncated: true,
|
|
87
98
|
totalChars: text.length,
|
|
88
|
-
head: text
|
|
99
|
+
head: sliceEncoded(text, MAX_RESPONSE_CHARS),
|
|
89
100
|
hint: 'Narrow the request (fields mask, pageSize) to get complete JSON.',
|
|
90
101
|
});
|
|
91
102
|
}
|