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.
Files changed (60) hide show
  1. package/README.md +5 -3
  2. package/dist/api-probe.d.ts +18 -0
  3. package/dist/api-probe.js +68 -0
  4. package/dist/arg-normalize.d.ts +19 -0
  5. package/dist/arg-normalize.js +90 -0
  6. package/dist/auth.js +15 -72
  7. package/dist/client.js +3 -1
  8. package/dist/discover.js +35 -14
  9. package/dist/discovery-client.d.ts +8 -1
  10. package/dist/discovery-client.js +67 -15
  11. package/dist/doctor.d.ts +12 -0
  12. package/dist/doctor.js +98 -15
  13. package/dist/http-transport.d.ts +3 -0
  14. package/dist/http-transport.js +2 -1
  15. package/dist/index.js +4 -1
  16. package/dist/oauth-consent.d.ts +25 -10
  17. package/dist/oauth-consent.js +85 -39
  18. package/dist/registry.d.ts +12 -0
  19. package/dist/registry.js +61 -3
  20. package/dist/scope-catalog.d.ts +1 -0
  21. package/dist/scope-catalog.js +17 -1
  22. package/dist/services.js +3 -1
  23. package/dist/tools/_errors.js +69 -5
  24. package/dist/tools/_local-files.d.ts +3 -0
  25. package/dist/tools/_local-files.js +34 -0
  26. package/dist/tools/account-wizard.d.ts +10 -0
  27. package/dist/tools/account-wizard.js +52 -19
  28. package/dist/tools/analytics.d.ts +18 -0
  29. package/dist/tools/analytics.js +279 -0
  30. package/dist/tools/drive.d.ts +2 -1
  31. package/dist/tools/drive.js +75 -33
  32. package/dist/tools/generated/_shared.d.ts +6 -0
  33. package/dist/tools/generated/_shared.js +11 -1
  34. package/dist/tools/generated/admin.js +160 -29
  35. package/dist/tools/generated/analytics.d.ts +2 -0
  36. package/dist/tools/generated/analytics.js +981 -0
  37. package/dist/tools/generated/chat.js +39 -12
  38. package/dist/tools/generated/classroom.js +56 -14
  39. package/dist/tools/generated/cloudidentity.js +18 -10
  40. package/dist/tools/generated/cloudsearch.js +4 -3
  41. package/dist/tools/generated/contacts.js +13 -4
  42. package/dist/tools/generated/drive.js +15 -6
  43. package/dist/tools/generated/drivelabels.js +33 -5
  44. package/dist/tools/generated/forms.js +1 -1
  45. package/dist/tools/generated/gmail.js +39 -12
  46. package/dist/tools/generated/index.js +2 -0
  47. package/dist/tools/generated/keep.js +3 -2
  48. package/dist/tools/generated/licensing.js +20 -4
  49. package/dist/tools/generated/meet.js +1 -1
  50. package/dist/tools/generated/reseller.js +8 -2
  51. package/dist/tools/generated/script.js +13 -3
  52. package/dist/tools/generated/searchconsole.js +4 -2
  53. package/dist/tools/generated/sheets.js +2 -1
  54. package/dist/tools/generated/tasks.js +7 -1
  55. package/dist/tools/generated/vault.js +18 -9
  56. package/dist/tools/generated/workspaceevents.js +3 -2
  57. package/dist/tools/gmail.js +3 -3
  58. package/dist/tools/google-api.d.ts +4 -1
  59. package/dist/tools/google-api.js +59 -15
  60. 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 sectionHttpDeferred(deps) {
181
- if (!transportsFrom(deps.env).includes('http'))
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
- return {
184
- id: 7,
185
- title: 'HTTP',
186
- verdict: 'unknown',
187
- lines: ['HTTP diagnostics (MCP_PUBLIC_URL canonicalization, MCP_OWNER_EMAILS, PRM/AS-metadata self-fetch) land with the OAuth authorization server (not yet built).'],
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 = sectionHttpDeferred(deps);
295
+ const http = await sectionHttp(deps, aliases);
213
296
  if (http)
214
297
  sections.push(http);
215
298
  return { verdict: overallVerdict(sections), sections };
@@ -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
@@ -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
- await server.connect(new StdioServerTransport());
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`);
@@ -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
- * Start the loopback server, await the OAuth redirect, validate the CSRF
22
- * `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
23
- * the caller to persist. Never process.exit's (safe inside a live server) and
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 awaitLoopbackConsent(client: OAuth2Client, expectedState: string, opts?: {
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<Record<string, unknown>>;
43
+ }): Promise<LoopbackConsent>;
@@ -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). The `auth --account` CLI keeps its own inline copy for now; this module
6
- // is the reusable, live-server-safe form (throws typed errors instead of
7
- // process.exit, and times out so a never-completed consent can't wedge a tool
8
- // call forever). The redirect matches the CLI exactly so an existing registered
9
- // Desktop client keeps working.
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(`E_LOOPBACK_PORT_IN_USE: port ${LOOPBACK_PORT} is already in use; close the other process and retry.`);
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
- /** Build the loopback OAuth2 client from env credentials (throws if unset). */
37
- export function buildConsentClient() {
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, LOOPBACK_REDIRECT);
49
+ return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, redirect);
41
50
  }
42
51
  /**
43
- * Start the loopback server, await the OAuth redirect, validate the CSRF
44
- * `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
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 awaitLoopbackConsent(client, expectedState, opts = {}) {
55
+ export function openLoopbackConsent(opts = {}) {
49
56
  const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
50
- return new Promise((resolve, reject) => {
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, LOOPBACK_REDIRECT).searchParams;
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
- reject(new ConsentDeniedError(error));
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 possible CSRF attempt. Aborting.</p>');
76
- reject(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
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
- reject(new ConsentDeniedError('no authorization code received'));
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
- resolve(tokens);
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
- reject(e);
121
+ fail(e);
92
122
  }
93
123
  });
94
124
  server.on('error', (err) => {
95
- if (timer)
96
- clearTimeout(timer);
97
- reject(err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err);
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
- server.listen(LOOPBACK_PORT, '127.0.0.1', () => {
100
- timer = setTimeout(() => {
101
- server.close();
102
- server.closeAllConnections();
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
  }
@@ -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
- const clientMeta = IRREVERSIBLE_TOOLS.has(name)
94
- ? { ...config._meta, 'anthropic/requiresUserInteraction': true }
95
- : config._meta;
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;
@@ -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;
@@ -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),