mcp-google-multi 6.0.0-alpha.2 → 6.0.0-alpha.21
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/api-probe.d.ts +18 -0
- package/dist/api-probe.js +68 -0
- package/dist/arg-normalize.d.ts +19 -0
- package/dist/arg-normalize.js +90 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +3 -1
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +67 -15
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +98 -15
- package/dist/http-transport.d.ts +3 -0
- package/dist/http-transport.js +2 -1
- package/dist/index.js +4 -1
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/registry.d.ts +12 -0
- package/dist/registry.js +61 -3
- package/dist/scope-catalog.d.ts +1 -0
- package/dist/scope-catalog.js +17 -1
- package/dist/services.js +3 -1
- package/dist/tools/_errors.js +69 -5
- package/dist/tools/_local-files.d.ts +3 -0
- package/dist/tools/_local-files.js +34 -0
- package/dist/tools/account-wizard.d.ts +10 -0
- package/dist/tools/account-wizard.js +52 -19
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +279 -0
- package/dist/tools/drive.d.ts +2 -1
- package/dist/tools/drive.js +75 -33
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +160 -29
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/chat.js +39 -12
- package/dist/tools/generated/classroom.js +56 -14
- package/dist/tools/generated/cloudidentity.js +18 -10
- package/dist/tools/generated/cloudsearch.js +4 -3
- package/dist/tools/generated/contacts.js +13 -4
- package/dist/tools/generated/drive.js +15 -6
- package/dist/tools/generated/drivelabels.js +33 -5
- package/dist/tools/generated/forms.js +1 -1
- package/dist/tools/generated/gmail.js +39 -12
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +3 -2
- package/dist/tools/generated/licensing.js +20 -4
- package/dist/tools/generated/meet.js +1 -1
- package/dist/tools/generated/reseller.js +8 -2
- package/dist/tools/generated/script.js +13 -3
- package/dist/tools/generated/searchconsole.js +4 -2
- package/dist/tools/generated/sheets.js +2 -1
- package/dist/tools/generated/tasks.js +7 -1
- package/dist/tools/generated/vault.js +18 -9
- package/dist/tools/generated/workspaceevents.js +3 -2
- package/dist/tools/gmail.js +3 -3
- package/dist/tools/google-api.d.ts +4 -1
- package/dist/tools/google-api.js +59 -15
- package/package.json +22 -17
package/dist/doctor.js
CHANGED
|
@@ -6,6 +6,9 @@ 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
8
|
import { configDir } from './config-file.js';
|
|
9
|
+
import { probeApiEnablement } from './api-probe.js';
|
|
10
|
+
import { resolveHttpConfig, HttpConfigError } from './http-config.js';
|
|
11
|
+
import { parseOwnerEmails } from './http-transport.js';
|
|
9
12
|
const MIN_NODE_MAJOR = 22;
|
|
10
13
|
const DEFAULT_DEPS = {
|
|
11
14
|
nodeVersion: process.versions.node,
|
|
@@ -23,17 +26,44 @@ const DEFAULT_DEPS = {
|
|
|
23
26
|
masterKeyProvenance: () => peekMasterKeyProvenance(),
|
|
24
27
|
anyTokensExist: (aliases) => aliases.some((a) => hasToken(a)),
|
|
25
28
|
fileExists: fs.existsSync,
|
|
29
|
+
probeApi: (alias) => probeApiEnablement(alias),
|
|
30
|
+
probeHttp: (cfg) => probeHttpEndpoints(cfg),
|
|
26
31
|
};
|
|
32
|
+
/** §7 live check: the advertised OAuth metadata must derive from MCP_PUBLIC_URL
|
|
33
|
+
* exactly — one mismatch between PRM `resource` / AS `issuer` and what clients
|
|
34
|
+
* compute from the public URL is the perpetual-401 interop bug (BR4). */
|
|
35
|
+
async function probeHttpEndpoints(cfg) {
|
|
36
|
+
try {
|
|
37
|
+
const prmRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-protected-resource`, {
|
|
38
|
+
signal: AbortSignal.timeout(2000),
|
|
39
|
+
redirect: 'manual',
|
|
40
|
+
});
|
|
41
|
+
if (!prmRes.ok)
|
|
42
|
+
return { ok: false, problem: `PRM endpoint returned HTTP ${prmRes.status}` };
|
|
43
|
+
const prm = (await prmRes.json());
|
|
44
|
+
if (prm.resource !== cfg.resourceUri) {
|
|
45
|
+
return { ok: false, problem: `PRM resource "${prm.resource}" does not match the expected "${cfg.resourceUri}"` };
|
|
46
|
+
}
|
|
47
|
+
const asRes = await fetch(`${cfg.publicUrl}/.well-known/oauth-authorization-server`, {
|
|
48
|
+
signal: AbortSignal.timeout(2000),
|
|
49
|
+
redirect: 'manual',
|
|
50
|
+
});
|
|
51
|
+
if (!asRes.ok)
|
|
52
|
+
return { ok: false, problem: `AS metadata endpoint returned HTTP ${asRes.status}` };
|
|
53
|
+
const as = (await asRes.json());
|
|
54
|
+
if (as.issuer !== cfg.publicUrl) {
|
|
55
|
+
return { ok: false, problem: `AS metadata issuer "${as.issuer}" does not match the public URL "${cfg.publicUrl}"` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { ok: false, unreachable: true };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
27
63
|
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
28
64
|
export function apiEnableLink(api) {
|
|
29
65
|
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
30
66
|
}
|
|
31
|
-
function transportsFrom(env) {
|
|
32
|
-
return (env.MCP_TRANSPORT ?? 'stdio')
|
|
33
|
-
.split(',')
|
|
34
|
-
.map((s) => s.trim().toLowerCase())
|
|
35
|
-
.filter(Boolean);
|
|
36
|
-
}
|
|
37
67
|
const LEGACY_ENV_KEYS = ['GOOGLE_ACCOUNTS', 'GOOGLE_OPTIONAL_SCOPES', 'GOOGLE_ADMIN_ACCOUNTS'];
|
|
38
68
|
function sectionRuntime(deps) {
|
|
39
69
|
const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
|
|
@@ -163,6 +193,9 @@ async function sectionApiEnablement(deps, aliases) {
|
|
|
163
193
|
// Network / transient: WARN with the target, never crash the report.
|
|
164
194
|
return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
|
|
165
195
|
}
|
|
196
|
+
if (results.length === 0) {
|
|
197
|
+
return { id: 6, title: 'API enablement', verdict: 'unknown', lines: [`No probeable service scopes granted on "${healthy}".`] };
|
|
198
|
+
}
|
|
166
199
|
const disabled = results.filter((r) => r.notEnabled);
|
|
167
200
|
const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
|
|
168
201
|
if (disabled.length > 0) {
|
|
@@ -177,15 +210,65 @@ async function sectionApiEnablement(deps, aliases) {
|
|
|
177
210
|
}
|
|
178
211
|
return { id: 6, title: 'API enablement', verdict: 'ok', lines: lines.length ? lines : ['(probed account, all enabled)'] };
|
|
179
212
|
}
|
|
180
|
-
function
|
|
181
|
-
|
|
213
|
+
async function sectionHttp(deps, aliases) {
|
|
214
|
+
const raw = (deps.env.MCP_TRANSPORT ?? '').trim().toLowerCase();
|
|
215
|
+
if (raw === '' || raw === 'stdio')
|
|
182
216
|
return null;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
217
|
+
let cfg;
|
|
218
|
+
try {
|
|
219
|
+
cfg = resolveHttpConfig(deps.env);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
id: 7,
|
|
224
|
+
title: 'HTTP',
|
|
225
|
+
verdict: 'fail',
|
|
226
|
+
slug: err instanceof HttpConfigError ? err.slug : 'E_HTTP_CONFIG',
|
|
227
|
+
lines: [err.message],
|
|
228
|
+
hint: 'Fix the MCP_* variable above and re-run doctor.',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const lines = [`bind ${cfg.host}:${cfg.port}, public URL ${cfg.publicUrl} (resource ${cfg.resourceUri})`];
|
|
232
|
+
let verdict = 'ok';
|
|
233
|
+
let slug;
|
|
234
|
+
const hints = [];
|
|
235
|
+
const owners = parseOwnerEmails(deps.env);
|
|
236
|
+
if (owners.length === 0) {
|
|
237
|
+
verdict = 'fail';
|
|
238
|
+
slug = 'E_OWNER_EMAILS_REQUIRED';
|
|
239
|
+
lines.push('MCP_OWNER_EMAILS is empty — nobody can pass the owner gate.');
|
|
240
|
+
hints.push('Set MCP_OWNER_EMAILS to the Google email(s) allowed to authenticate.');
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
const known = new Set(aliases.map((a) => deps.accountHealth(a).email.toLowerCase()));
|
|
244
|
+
const strangers = known.size > 0 ? owners.filter((o) => !known.has(o)) : [];
|
|
245
|
+
lines.push(`owner gate: ${owners.length} email(s)${strangers.length ? `, ${strangers.length} matching no configured account` : ''}`);
|
|
246
|
+
if (strangers.length > 0) {
|
|
247
|
+
verdict = 'warn';
|
|
248
|
+
slug = 'W_OWNER_EMAIL_UNKNOWN';
|
|
249
|
+
hints.push(`Owner entry ${strangers.join(', ')} is not a configured account email. ` +
|
|
250
|
+
'If that is a misspelling of your account email, sign-in will be refused — fix MCP_OWNER_EMAILS.');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (verdict !== 'fail' && deps.probeHttp) {
|
|
254
|
+
const probe = await deps.probeHttp(cfg);
|
|
255
|
+
if (probe.ok) {
|
|
256
|
+
lines.push('live: PRM + AS metadata verified at the public URL');
|
|
257
|
+
}
|
|
258
|
+
else if (probe.unreachable) {
|
|
259
|
+
if (verdict === 'ok')
|
|
260
|
+
verdict = 'unknown';
|
|
261
|
+
lines.push(`live: ${cfg.publicUrl} not reachable (server not running?)`);
|
|
262
|
+
hints.push('Start the server (MCP_TRANSPORT=http) and re-run doctor for the live endpoint checks.');
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
verdict = 'fail';
|
|
266
|
+
slug = 'E_HTTP_METADATA_MISMATCH';
|
|
267
|
+
lines.push(`live: ${probe.problem}`);
|
|
268
|
+
hints.push('The advertised OAuth metadata must derive from MCP_PUBLIC_URL exactly; restart the server after changing it.');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { id: 7, title: 'HTTP', verdict, ...(slug ? { slug } : {}), lines, ...(hints.length ? { hint: hints.join('\n') } : {}) };
|
|
189
272
|
}
|
|
190
273
|
const RANK = { ok: 0, unknown: 0, warn: 1, fail: 2 };
|
|
191
274
|
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
@@ -209,7 +292,7 @@ export async function runDiagnostics(deps = DEFAULT_DEPS) {
|
|
|
209
292
|
sections.push(tokens, scopes);
|
|
210
293
|
sections.push(await sectionApiEnablement(deps, aliases));
|
|
211
294
|
}
|
|
212
|
-
const http =
|
|
295
|
+
const http = await sectionHttp(deps, aliases);
|
|
213
296
|
if (http)
|
|
214
297
|
sections.push(http);
|
|
215
298
|
return { verdict: overallVerdict(sections), sections };
|
package/dist/http-transport.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import type { HttpConfig } from './http-config.js';
|
|
4
|
+
import { type ArgShape } from './arg-normalize.js';
|
|
4
5
|
export type AuthOutcome = {
|
|
5
6
|
ok: true;
|
|
6
7
|
} | {
|
|
@@ -28,6 +29,8 @@ export interface HttpHostOptions {
|
|
|
28
29
|
/** Deadline for a single /mcp dispatch; a hung handler past this releases the
|
|
29
30
|
* shared lock instead of wedging the transport (default 120s). */
|
|
30
31
|
dispatchTimeoutMs?: number;
|
|
32
|
+
/** tools/call argument-key normalization lookup (arg-normalize.ts); absent = off. */
|
|
33
|
+
argShapeFor?: (tool: string) => ArgShape | undefined;
|
|
31
34
|
}
|
|
32
35
|
export declare function parseOwnerEmails(env?: NodeJS.ProcessEnv): string[];
|
|
33
36
|
/** Front guard: an Origin, if present, must be allowlisted; a Host must be
|
package/dist/http-transport.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// loopback-owner authenticator so the local-HTTP model works before the AS lands.
|
|
6
6
|
import { createServer } from 'node:http';
|
|
7
7
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
import { withArgNormalization } from './arg-normalize.js';
|
|
8
9
|
// A hung handler that keeps the connection open would otherwise hold the global
|
|
9
10
|
// serialize() lock forever. Generous by default so slow-but-valid calls (large
|
|
10
11
|
// Drive exports, fan-out) still finish; the point is only to guarantee release.
|
|
@@ -182,7 +183,7 @@ export class HttpTransportHost {
|
|
|
182
183
|
timer = setTimeout(() => resolve('timeout'), deadlineMs);
|
|
183
184
|
timer.unref?.();
|
|
184
185
|
});
|
|
185
|
-
await this.opts.server.connect(transport);
|
|
186
|
+
await this.opts.server.connect(this.opts.argShapeFor ? withArgNormalization(transport, this.opts.argShapeFor, this.opts.log) : transport);
|
|
186
187
|
// Reflect the dispatch into a non-rejecting arm: if the deadline wins the
|
|
187
188
|
// race, an orphaned handler settling later must not surface as an unhandled
|
|
188
189
|
// rejection — but a genuine dispatch error still propagates (rethrown below).
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { isAllowed, describePolicy } from './write-control.js';
|
|
|
20
20
|
import { buildIdentityContext } from './identity.js';
|
|
21
21
|
import { registerSetupPrompt } from './setup-prompt.js';
|
|
22
22
|
import { applyNetTuning } from './net-tuning.js';
|
|
23
|
+
import { argNormalizationEnabled, withArgNormalization } from './arg-normalize.js';
|
|
23
24
|
applyNetTuning();
|
|
24
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
26
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
@@ -189,7 +190,8 @@ async function main() {
|
|
|
189
190
|
const registry = buildRegistry(server, buildIdentityContext(process.env, { transport: 'stdio' }));
|
|
190
191
|
registry.installListHandler();
|
|
191
192
|
registerSetupPrompt(server);
|
|
192
|
-
|
|
193
|
+
const stdioTransport = new StdioServerTransport();
|
|
194
|
+
await server.connect(argNormalizationEnabled() ? withArgNormalization(stdioTransport, (n) => registry.argShape(n)) : stdioTransport);
|
|
193
195
|
}
|
|
194
196
|
if (wantHttp) {
|
|
195
197
|
const { HttpTransportHost, parseOwnerEmails } = await import('./http-transport.js');
|
|
@@ -280,6 +282,7 @@ async function main() {
|
|
|
280
282
|
authenticate: authServer.authenticate,
|
|
281
283
|
routes: authServer.routes,
|
|
282
284
|
log: (l) => process.stderr.write(`[http] ${l}\n`),
|
|
285
|
+
argShapeFor: argNormalizationEnabled() ? (n) => registry.argShape(n) : undefined,
|
|
283
286
|
});
|
|
284
287
|
await host.start();
|
|
285
288
|
process.stderr.write(`HTTP transport listening on http://${httpCfg.host}:${httpCfg.port} (public ${httpCfg.publicUrl})\n`);
|
package/dist/oauth-consent.d.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
-
export declare const LOOPBACK_PORT = 4242;
|
|
3
|
-
export declare const LOOPBACK_REDIRECT = "http://localhost:4242/oauth2callback";
|
|
4
2
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
5
3
|
export declare class ClientCredentialsMissingError extends Error {
|
|
6
4
|
constructor();
|
|
@@ -14,15 +12,32 @@ export declare class ConsentTimeoutError extends Error {
|
|
|
14
12
|
export declare class ConsentDeniedError extends Error {
|
|
15
13
|
constructor(reason: string);
|
|
16
14
|
}
|
|
15
|
+
export declare const TESTING_MODE_WARNING: string;
|
|
17
16
|
export declare function hasClientCredentials(): boolean;
|
|
18
|
-
/** Build the loopback OAuth2 client from env credentials (throws if unset). */
|
|
19
|
-
export declare function buildConsentClient(): OAuth2Client;
|
|
20
17
|
/**
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
18
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
19
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
20
|
+
* listener is bound.
|
|
25
21
|
*/
|
|
26
|
-
export declare function
|
|
22
|
+
export declare function buildConsentClient(redirect: string): OAuth2Client;
|
|
23
|
+
export interface LoopbackConsent {
|
|
24
|
+
/** `http://localhost:<ephemeral port>/oauth2callback` — build the auth URL from this. */
|
|
25
|
+
redirect: string;
|
|
26
|
+
/**
|
|
27
|
+
* Await the OAuth redirect, validate the CSRF `state` (RFC 6749 §10.12), and
|
|
28
|
+
* exchange the code. Returns the token set for the caller to persist.
|
|
29
|
+
*/
|
|
30
|
+
finish(client: OAuth2Client, expectedState: string): Promise<Record<string, unknown>>;
|
|
31
|
+
/**
|
|
32
|
+
* Abandon the consent: shuts the listener down WITHOUT settling finish(), so
|
|
33
|
+
* an abandoned flow can never surface as an unhandled rejection later.
|
|
34
|
+
*/
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
39
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
40
|
+
*/
|
|
41
|
+
export declare function openLoopbackConsent(opts?: {
|
|
27
42
|
timeoutMs?: number;
|
|
28
|
-
}): Promise<
|
|
43
|
+
}): Promise<LoopbackConsent>;
|
package/dist/oauth-consent.js
CHANGED
|
@@ -2,13 +2,11 @@ import { OAuth2Client } from 'googleapis-common';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
4
|
// Shared loopback OAuth consent (leg C of cc-auth), used by the account wizard
|
|
5
|
-
// (B7)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
export const LOOPBACK_PORT = 4242;
|
|
11
|
-
export const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/oauth2callback`;
|
|
5
|
+
// (B7) and the `auth --account` CLI. Live-server-safe: typed errors instead of
|
|
6
|
+
// process.exit, and a timeout so a never-completed consent can't wedge a tool
|
|
7
|
+
// call forever. The listener binds an EPHEMERAL loopback port (RFC 8252 §7.3;
|
|
8
|
+
// Google Desktop clients accept any http://localhost:<port> redirect), so a
|
|
9
|
+
// second local process on a fixed port can never break auth.
|
|
12
10
|
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
13
11
|
export class ClientCredentialsMissingError extends Error {
|
|
14
12
|
constructor() {
|
|
@@ -17,7 +15,7 @@ export class ClientCredentialsMissingError extends Error {
|
|
|
17
15
|
}
|
|
18
16
|
export class LoopbackPortInUseError extends Error {
|
|
19
17
|
constructor() {
|
|
20
|
-
super(
|
|
18
|
+
super('E_LOOPBACK_PORT_IN_USE: the loopback consent listener could not bind a port; retry.');
|
|
21
19
|
}
|
|
22
20
|
}
|
|
23
21
|
export class ConsentTimeoutError extends Error {
|
|
@@ -30,80 +28,128 @@ export class ConsentDeniedError extends Error {
|
|
|
30
28
|
super(`E_CONSENT_DENIED: ${reason}`);
|
|
31
29
|
}
|
|
32
30
|
}
|
|
31
|
+
// Surfaced after every successful consent: the expiry is invisible until the
|
|
32
|
+
// token dies a week later as reauth_required, so the moment of success is the
|
|
33
|
+
// one place the warning is guaranteed to be seen. Full walkthrough in
|
|
34
|
+
// docs/google-cloud-setup.md.
|
|
35
|
+
export const TESTING_MODE_WARNING = 'Heads-up: while your OAuth client\'s Publishing status is "Testing", Google expires refresh tokens after 7 days (weekly re-auth for every account). ' +
|
|
36
|
+
'When your setup works, set it to "In production" at https://console.cloud.google.com/auth/audience. ' +
|
|
37
|
+
'No verification review is needed for personal use; see docs/google-cloud-setup.md.';
|
|
33
38
|
export function hasClientCredentials() {
|
|
34
39
|
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
|
35
40
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Build the loopback OAuth2 client from env credentials (throws if unset).
|
|
43
|
+
* `redirect` comes from openLoopbackConsent(): the port is only known once the
|
|
44
|
+
* listener is bound.
|
|
45
|
+
*/
|
|
46
|
+
export function buildConsentClient(redirect) {
|
|
38
47
|
if (!hasClientCredentials())
|
|
39
48
|
throw new ClientCredentialsMissingError();
|
|
40
|
-
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET,
|
|
49
|
+
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, redirect);
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the caller to persist. Never process.exit's (safe inside a live server) and
|
|
46
|
-
* times out so a stalled consent can't wedge a tool call.
|
|
52
|
+
* Bind the loopback listener first (ephemeral port), so the redirect URI is
|
|
53
|
+
* known before the auth URL is built and the callback can't race the browser.
|
|
47
54
|
*/
|
|
48
|
-
export function
|
|
55
|
+
export function openLoopbackConsent(opts = {}) {
|
|
49
56
|
const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
|
|
50
|
-
return new Promise((
|
|
57
|
+
return new Promise((resolveOpen, rejectOpen) => {
|
|
58
|
+
let redirect = '';
|
|
51
59
|
let timer;
|
|
60
|
+
let closed = false;
|
|
61
|
+
let pending;
|
|
62
|
+
const shutdown = () => {
|
|
63
|
+
if (closed)
|
|
64
|
+
return;
|
|
65
|
+
closed = true;
|
|
66
|
+
if (timer)
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
server.close();
|
|
69
|
+
server.closeAllConnections();
|
|
70
|
+
};
|
|
71
|
+
const fail = (err) => {
|
|
72
|
+
const p = pending;
|
|
73
|
+
pending = undefined;
|
|
74
|
+
shutdown();
|
|
75
|
+
p?.reject(err);
|
|
76
|
+
};
|
|
52
77
|
const server = http.createServer(async (req, res) => {
|
|
53
78
|
if (!req.url || !req.url.startsWith('/oauth2callback')) {
|
|
54
79
|
res.writeHead(404).end();
|
|
55
80
|
return;
|
|
56
81
|
}
|
|
82
|
+
if (!pending) {
|
|
83
|
+
// Only reachable if something other than our own browser launch hit
|
|
84
|
+
// the port before finish() armed the exchange; nothing to do with it.
|
|
85
|
+
res.writeHead(503).end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const p = pending;
|
|
57
89
|
const done = (code, body) => {
|
|
58
90
|
res.writeHead(code, { 'Content-Type': 'text/html' });
|
|
59
91
|
res.end(body);
|
|
60
|
-
if (timer)
|
|
61
|
-
clearTimeout(timer);
|
|
62
|
-
server.close();
|
|
63
|
-
server.closeAllConnections();
|
|
64
92
|
};
|
|
65
93
|
try {
|
|
66
|
-
const qs = new URL(req.url,
|
|
94
|
+
const qs = new URL(req.url, redirect).searchParams;
|
|
67
95
|
const error = qs.get('error');
|
|
68
96
|
if (error) {
|
|
69
97
|
done(400, `<p>Authorization denied: ${error}</p>`);
|
|
70
|
-
|
|
98
|
+
fail(new ConsentDeniedError(error));
|
|
71
99
|
return;
|
|
72
100
|
}
|
|
73
101
|
const returnedState = qs.get('state');
|
|
74
|
-
if (returnedState !== expectedState) {
|
|
75
|
-
done(400, '<p>State mismatch
|
|
76
|
-
|
|
102
|
+
if (returnedState !== p.expectedState) {
|
|
103
|
+
done(400, '<p>State mismatch: possible CSRF attempt. Aborting.</p>');
|
|
104
|
+
fail(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
|
|
77
105
|
return;
|
|
78
106
|
}
|
|
79
107
|
const code = qs.get('code');
|
|
80
108
|
if (!code) {
|
|
81
109
|
done(400, '<p>No authorization code received.</p>');
|
|
82
|
-
|
|
110
|
+
fail(new ConsentDeniedError('no authorization code received'));
|
|
83
111
|
return;
|
|
84
112
|
}
|
|
85
|
-
const { tokens } = await client.getToken(code);
|
|
113
|
+
const { tokens } = await p.client.getToken(code);
|
|
86
114
|
done(200, '<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
87
|
-
|
|
115
|
+
pending = undefined;
|
|
116
|
+
shutdown();
|
|
117
|
+
p.resolve(tokens);
|
|
88
118
|
}
|
|
89
119
|
catch (e) {
|
|
90
120
|
done(500, '<p>Internal error during authentication.</p>');
|
|
91
|
-
|
|
121
|
+
fail(e);
|
|
92
122
|
}
|
|
93
123
|
});
|
|
94
124
|
server.on('error', (err) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
125
|
+
const mapped = err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err;
|
|
126
|
+
if (pending) {
|
|
127
|
+
fail(mapped);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
shutdown();
|
|
131
|
+
rejectOpen(mapped);
|
|
132
|
+
}
|
|
98
133
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
reject(new ConsentTimeoutError());
|
|
104
|
-
}, timeoutMs);
|
|
134
|
+
// Bind to loopback only — never expose the OAuth callback to the local network.
|
|
135
|
+
server.listen(0, '127.0.0.1', () => {
|
|
136
|
+
redirect = `http://localhost:${server.address().port}/oauth2callback`;
|
|
137
|
+
timer = setTimeout(() => fail(new ConsentTimeoutError()), timeoutMs);
|
|
105
138
|
// unref so a pending consent never keeps the process alive on its own.
|
|
106
139
|
timer.unref?.();
|
|
140
|
+
resolveOpen({
|
|
141
|
+
redirect,
|
|
142
|
+
finish(client, expectedState) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
if (closed) {
|
|
145
|
+
reject(new ConsentTimeoutError());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pending = { client, expectedState, resolve, reject };
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
close: shutdown,
|
|
152
|
+
});
|
|
107
153
|
});
|
|
108
154
|
});
|
|
109
155
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { type Policy } from './write-control.js';
|
|
4
|
+
import type { ArgShape } from './arg-normalize.js';
|
|
4
5
|
export type Cud = 'read' | 'create' | 'update' | 'delete';
|
|
5
6
|
export type DiscoveryMode = 'lazy' | 'curated' | 'eager';
|
|
6
7
|
export declare function resolveDiscoveryMode(env?: NodeJS.ProcessEnv): DiscoveryMode;
|
|
@@ -38,6 +39,7 @@ export declare class ToolRegistry {
|
|
|
38
39
|
readonly registerTool: McpServer['registerTool'];
|
|
39
40
|
private readonly revealed;
|
|
40
41
|
private readonly jsonSchemaCache;
|
|
42
|
+
private readonly argShapeCache;
|
|
41
43
|
private readonly compactOutput;
|
|
42
44
|
private registeringMeta;
|
|
43
45
|
/** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
|
|
@@ -48,7 +50,17 @@ export declare class ToolRegistry {
|
|
|
48
50
|
constructor(server: McpServer, policy: Policy, mode?: DiscoveryMode);
|
|
49
51
|
registerMeta: McpServer['registerTool'];
|
|
50
52
|
services(): string[];
|
|
53
|
+
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
54
|
+
* normalization; the kind drives value coercion on renamed keys). */
|
|
55
|
+
argShape(name: string): ArgShape | undefined;
|
|
51
56
|
catalog(service: string, query?: string): CatalogOperation[];
|
|
57
|
+
/** Op-name vocabulary for a service, split by provenance so the capped
|
|
58
|
+
* discover descriptions can list curated ops and only summarize the
|
|
59
|
+
* generated long tail. */
|
|
60
|
+
opNames(service: string): {
|
|
61
|
+
curated: string[];
|
|
62
|
+
generated: string[];
|
|
63
|
+
};
|
|
52
64
|
reveal(service: string): boolean;
|
|
53
65
|
/** discover_all: advertise the full curated set at once. Idempotent. */
|
|
54
66
|
expand(): boolean;
|
package/dist/registry.js
CHANGED
|
@@ -4,6 +4,11 @@ import { isAllowed, writeDisabledResult, IRREVERSIBLE_TOOLS } from './write-cont
|
|
|
4
4
|
import { getAccountSet, refreshAccountSetIfStale } from './accounts.js';
|
|
5
5
|
import { compactResult, trimEnabled } from './trim.js';
|
|
6
6
|
import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
|
|
7
|
+
import { MAX_RESPONSE_CHARS } from './executor.js';
|
|
8
|
+
// Client-side result budget advertised for tools that do not declare their own
|
|
9
|
+
// (fat readers do; see trim.ts). ~50k chars stays well inside a default client
|
|
10
|
+
// context limit while leaving room for real list payloads.
|
|
11
|
+
const DEFAULT_MAX_RESULT_CHARS = 50_000;
|
|
7
12
|
const DISCOVERY_MODES = ['lazy', 'curated', 'eager'];
|
|
8
13
|
export function resolveDiscoveryMode(env = process.env) {
|
|
9
14
|
const raw = (env.GOOGLE_DISCOVERY ?? 'lazy').trim();
|
|
@@ -33,6 +38,23 @@ const SERVICE_OVERRIDES = {
|
|
|
33
38
|
};
|
|
34
39
|
// read tools that write local files — same savePath fanned across accounts would clobber
|
|
35
40
|
const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
|
|
41
|
+
/** Unwrap optional/default/nullable to the declared scalar kind (zod 4 defs). */
|
|
42
|
+
function scalarKindOf(field) {
|
|
43
|
+
let cur = field;
|
|
44
|
+
for (let i = 0; i < 4 && cur?._zod?.def; i++) {
|
|
45
|
+
const def = cur._zod.def;
|
|
46
|
+
if (def.type === 'number')
|
|
47
|
+
return 'number';
|
|
48
|
+
if (def.type === 'boolean')
|
|
49
|
+
return 'boolean';
|
|
50
|
+
if (def.type === 'optional' || def.type === 'default' || def.type === 'nullable') {
|
|
51
|
+
cur = def.innerType;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
return 'other';
|
|
55
|
+
}
|
|
56
|
+
return 'other';
|
|
57
|
+
}
|
|
36
58
|
function isAccountEnum(field) {
|
|
37
59
|
const def = field?._zod?.def;
|
|
38
60
|
if (!def)
|
|
@@ -64,6 +86,7 @@ export class ToolRegistry {
|
|
|
64
86
|
registerTool;
|
|
65
87
|
revealed = new Set();
|
|
66
88
|
jsonSchemaCache = new Map();
|
|
89
|
+
argShapeCache = new Map();
|
|
67
90
|
compactOutput = trimEnabled();
|
|
68
91
|
registeringMeta = false;
|
|
69
92
|
/** Configured visibility mode (GOOGLE_DISCOVERY); default lazy = v5 exact. */
|
|
@@ -90,9 +113,15 @@ export class ToolRegistry {
|
|
|
90
113
|
// A12: forced per-call human approval on the irreversible set, even in
|
|
91
114
|
// bypass mode. Client-enforced via the wire _meta (Claude Code reads
|
|
92
115
|
// anthropic/* ONLY there); the server verdict stays separate.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
// Every tool also advertises a result-size budget: fat readers declare
|
|
117
|
+
// their own, everything else inherits the default, so an uncapped tool
|
|
118
|
+
// can never blow past a client's context limit. Generated tools align
|
|
119
|
+
// with the executor's server-side cap.
|
|
120
|
+
const clientMeta = {
|
|
121
|
+
'anthropic/maxResultSizeChars': config.cud !== undefined ? MAX_RESPONSE_CHARS : DEFAULT_MAX_RESULT_CHARS,
|
|
122
|
+
...config._meta,
|
|
123
|
+
...(IRREVERSIBLE_TOOLS.has(name) ? { 'anthropic/requiresUserInteraction': true } : {}),
|
|
124
|
+
};
|
|
96
125
|
// never fan out meta tools: google_api_call infers cud=read but executes writes
|
|
97
126
|
let inputShape = config.inputSchema ?? {};
|
|
98
127
|
let baseHandler = handler;
|
|
@@ -181,6 +210,21 @@ export class ToolRegistry {
|
|
|
181
210
|
services() {
|
|
182
211
|
return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
|
|
183
212
|
}
|
|
213
|
+
/** Declared input-schema keys + scalar kinds for one tool (tools/call arg
|
|
214
|
+
* normalization; the kind drives value coercion on renamed keys). */
|
|
215
|
+
argShape(name) {
|
|
216
|
+
const cached = this.argShapeCache.get(name);
|
|
217
|
+
if (cached)
|
|
218
|
+
return cached;
|
|
219
|
+
const entry = this.tools.find((t) => t.name === name);
|
|
220
|
+
if (!entry)
|
|
221
|
+
return undefined;
|
|
222
|
+
const shape = new Map();
|
|
223
|
+
for (const [key, field] of Object.entries(entry.inputShape))
|
|
224
|
+
shape.set(key, scalarKindOf(field));
|
|
225
|
+
this.argShapeCache.set(name, shape);
|
|
226
|
+
return shape;
|
|
227
|
+
}
|
|
184
228
|
catalog(service, query) {
|
|
185
229
|
const q = query?.trim().toLowerCase();
|
|
186
230
|
return this.tools
|
|
@@ -193,6 +237,20 @@ export class ToolRegistry {
|
|
|
193
237
|
cud: t.cud,
|
|
194
238
|
}));
|
|
195
239
|
}
|
|
240
|
+
/** Op-name vocabulary for a service, split by provenance so the capped
|
|
241
|
+
* discover descriptions can list curated ops and only summarize the
|
|
242
|
+
* generated long tail. */
|
|
243
|
+
opNames(service) {
|
|
244
|
+
const strip = (n) => (n.startsWith(`${service}_`) ? n.slice(service.length + 1) : n);
|
|
245
|
+
const curated = [];
|
|
246
|
+
const generated = [];
|
|
247
|
+
for (const t of this.tools) {
|
|
248
|
+
if (t.meta || t.service !== service)
|
|
249
|
+
continue;
|
|
250
|
+
(t.generated ? generated : curated).push(strip(t.name));
|
|
251
|
+
}
|
|
252
|
+
return { curated: [...new Set(curated)], generated: [...new Set(generated)] };
|
|
253
|
+
}
|
|
196
254
|
reveal(service) {
|
|
197
255
|
if (this.revealed.has(service))
|
|
198
256
|
return false;
|
package/dist/scope-catalog.d.ts
CHANGED
|
@@ -17,3 +17,4 @@ export declare const BUNDLE_ALIASES: Record<string, string>;
|
|
|
17
17
|
export declare function resolveBundleAliases(bundles: string[]): string[];
|
|
18
18
|
/** Closest catalog key for E_UNKNOWN_BUNDLE remediation (edit distance <= 2). */
|
|
19
19
|
export declare function closestBundle(name: string): string | undefined;
|
|
20
|
+
export declare function editDistance(a: string, b: string): number;
|
package/dist/scope-catalog.js
CHANGED
|
@@ -108,6 +108,22 @@ export const BUNDLE_CATALOG = {
|
|
|
108
108
|
description: 'Read Gmail Postmaster Tools deliverability data.',
|
|
109
109
|
risk: 'low',
|
|
110
110
|
},
|
|
111
|
+
analytics: {
|
|
112
|
+
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
|
|
113
|
+
description: 'Read Google Analytics (GA4): run reports and inspect accounts, properties and their configuration.',
|
|
114
|
+
risk: 'low',
|
|
115
|
+
},
|
|
116
|
+
// Includes readonly so it is self-sufficient: analytics.edit alone does not
|
|
117
|
+
// authorize Data API reads. The smaller `analytics` bundle stays the hint
|
|
118
|
+
// target for read scopes (scope-observability sorts bundles by size).
|
|
119
|
+
analytics_write: {
|
|
120
|
+
scopes: [
|
|
121
|
+
'https://www.googleapis.com/auth/analytics.readonly',
|
|
122
|
+
'https://www.googleapis.com/auth/analytics.edit',
|
|
123
|
+
],
|
|
124
|
+
description: 'Edit Google Analytics (GA4) configuration: properties, data streams, key events, custom dimensions and metrics. Includes read access.',
|
|
125
|
+
risk: 'medium',
|
|
126
|
+
},
|
|
111
127
|
groupssettings: {
|
|
112
128
|
scopes: ['https://www.googleapis.com/auth/apps.groups.settings'],
|
|
113
129
|
description: 'Change Google Groups settings for the domain.',
|
|
@@ -169,7 +185,7 @@ export function closestBundle(name) {
|
|
|
169
185
|
}
|
|
170
186
|
return best;
|
|
171
187
|
}
|
|
172
|
-
function editDistance(a, b) {
|
|
188
|
+
export function editDistance(a, b) {
|
|
173
189
|
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
174
190
|
for (let j = 1; j <= b.length; j++)
|
|
175
191
|
dp[0][j] = j;
|
package/dist/services.js
CHANGED
|
@@ -11,6 +11,7 @@ import { registerSlidesTools } from './tools/slides.js';
|
|
|
11
11
|
import { registerFormsTools } from './tools/forms.js';
|
|
12
12
|
import { registerChatTools } from './tools/chat.js';
|
|
13
13
|
import { registerAdminTools } from './tools/admin.js';
|
|
14
|
+
import { registerAnalyticsTools } from './tools/analytics.js';
|
|
14
15
|
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
15
16
|
export const SERVICES = [
|
|
16
17
|
{ name: 'gmail', register: registerGmailTools },
|
|
@@ -25,9 +26,10 @@ export const SERVICES = [
|
|
|
25
26
|
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
26
27
|
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
27
28
|
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
29
|
+
{ name: 'analytics', register: registerAnalyticsTools, enabled: () => { const b = new Set(getOptionalBundles()); return b.has('analytics') || b.has('analytics_write'); } },
|
|
28
30
|
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
29
31
|
];
|
|
30
|
-
// Generated-only services with opt-in scopes; admin/forms/chat reuse their curated gate in buildRegistry,
|
|
32
|
+
// Generated-only services with opt-in scopes; admin/forms/chat/analytics reuse their curated gate in buildRegistry,
|
|
31
33
|
// and workspaceevents is deliberately absent — no dedicated scope (subscriptions use resource scopes).
|
|
32
34
|
const bundleGate = (name) => ({
|
|
33
35
|
enabled: () => new Set(getOptionalBundles()).has(name),
|