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
@@ -20,6 +20,10 @@ const RETRIABLE_NET_CODES = new Set([
20
20
  'EHOSTUNREACH', 'EPIPE', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
21
21
  ]);
22
22
  const NET_CODES = new Set([...RETRIABLE_NET_CODES, 'ENOTFOUND']);
23
+ // Local-filesystem syscall codes from caller-supplied paths (localPath/savePath).
24
+ // String codes, so they never collide with Google's numeric statuses; the
25
+ // network codes above are deliberately excluded.
26
+ const LOCAL_FS_CODES = new Set(['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR', 'EPERM', 'ELOOP', 'ENAMETOOLONG', 'ENOSPC']);
23
27
  /** First known network code on the error or its cause chain (GaxiosError.cause
24
28
  * -> FetchError; undici TypeError.cause -> AggregateError.errors). */
25
29
  function netCodeOf(error) {
@@ -115,16 +119,48 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
115
119
  account,
116
120
  };
117
121
  }
118
- return { error: 'forbidden', message, hint: forbiddenHint, retriable: false, account };
122
+ return {
123
+ error: 'forbidden',
124
+ message,
125
+ hint: forbiddenHint ??
126
+ `Google denied access at the resource level (not a scope problem): check that "${account}" actually has access to this item, e.g. it is shared with that account, and that you picked the right account alias.`,
127
+ retriable: false,
128
+ account,
129
+ };
119
130
  }
120
131
  if (status === 400 && /invalid[_ ]scope/i.test(message)) {
121
- return { error: 'invalid_scope', message, retriable: false, account };
132
+ return {
133
+ error: 'invalid_scope',
134
+ message,
135
+ hint: 'One of the requested OAuth scopes is malformed or unavailable to this client. Run `config check` to review the account scope profile, fix it, then re-auth.',
136
+ retriable: false,
137
+ account,
138
+ };
122
139
  }
123
140
  if (status === 404) {
124
- return { error: 'not_found', message, retriable: false, account };
141
+ return {
142
+ error: 'not_found',
143
+ message,
144
+ hint: `The ID does not exist or is not visible to "${account}". IDs are account-specific: re-fetch it with the matching list/search tool, and check the account alias is the one that owns the resource.`,
145
+ retriable: false,
146
+ account,
147
+ };
125
148
  }
126
149
  if (status === 429) {
127
150
  const retryAfter = error?.response?.headers?.['retry-after'];
151
+ // GA4 quota exhaustion ("Exhausted property tokens ...") is a per-property
152
+ // token bucket, not a transient rate spike: shrinking the request is the
153
+ // lever that helps, and a blind immediate retry only burns more tokens.
154
+ if (/property tokens/i.test(message)) {
155
+ return {
156
+ error: 'rate_limited',
157
+ message,
158
+ hint: 'GA4 quotas are per-property token buckets that refill over the hour/day. ' +
159
+ 'Narrow the date range, request fewer dimensions/metrics/rows, and pass returnPropertyQuota to see the remaining tokens before retrying.',
160
+ retriable: true,
161
+ account,
162
+ };
163
+ }
128
164
  return {
129
165
  error: 'rate_limited',
130
166
  message,
@@ -134,9 +170,27 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
134
170
  };
135
171
  }
136
172
  if (status !== undefined && status >= 500) {
137
- return { error: 'upstream_error', message, retriable: true, account };
173
+ return {
174
+ error: 'upstream_error',
175
+ message,
176
+ hint: 'Google-side server error, usually transient: retry, with backoff if it repeats.',
177
+ retriable: true,
178
+ account,
179
+ };
138
180
  }
139
181
  if (status === undefined) {
182
+ const fsCode = typeof error?.code === 'string' && LOCAL_FS_CODES.has(error.code) ? error.code : undefined;
183
+ if (fsCode) {
184
+ const p = typeof error?.path === 'string' ? ` "${error.path}"` : '';
185
+ return {
186
+ error: 'invalid_params',
187
+ message: `Cannot access local path${p}: ${fsCode}`,
188
+ hint: 'The path must exist on the machine running this server and be accessible to it. ' +
189
+ 'When the server runs remotely (HTTP transport), paths on your own machine are not visible to it.',
190
+ retriable: false,
191
+ account,
192
+ };
193
+ }
140
194
  const netCode = netCodeOf(error);
141
195
  if (netCode) {
142
196
  return {
@@ -149,7 +203,17 @@ export function mapGoogleError(error, account, forbiddenHint, scopeContext) {
149
203
  };
150
204
  }
151
205
  }
152
- return { error: 'upstream_error', message, retriable: false, account };
206
+ // Passthrough floor: still emit a hint so no envelope leaves the mapper
207
+ // without a next step. A 400 here is a request Google parsed and rejected.
208
+ return {
209
+ error: 'upstream_error',
210
+ message,
211
+ hint: status === 400
212
+ ? 'Google rejected the request as malformed: an argument is likely wrong or missing. Check IDs, enum values and formats against the tool description before retrying.'
213
+ : 'Unclassified error: the message above is the best signal. Retry only if it reads as transient; otherwise change the request rather than repeating it.',
214
+ retriable: false,
215
+ account,
216
+ };
153
217
  }
154
218
  export function handleGoogleApiError(error, account, forbiddenHint, scopeContext) {
155
219
  const envelope = mapGoogleError(error, account, forbiddenHint, scopeContext);
@@ -0,0 +1,3 @@
1
+ import * as fs from 'fs';
2
+ export declare function prepareLocalDest(savePath: string, filename: string): string;
3
+ export declare function openLocalReadStream(localPath: string): Promise<fs.ReadStream>;
@@ -0,0 +1,34 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ // path.basename() is a traversal guard — a caller-supplied filename must never escape savePath.
4
+ export function prepareLocalDest(savePath, filename) {
5
+ const name = path.basename(filename);
6
+ // Agents routinely pass the intended FILE path as savePath and repeat the
7
+ // name in `filename`; a blind join would mkdir a directory named like the
8
+ // file and bury the download inside it, so strip the duplicated leaf.
9
+ const dir = path.basename(savePath) === name ? path.dirname(savePath) : savePath;
10
+ const dest = path.join(dir, name);
11
+ fs.mkdirSync(dir, { recursive: true });
12
+ return dest;
13
+ }
14
+ // fs.createReadStream() reports an unopenable path as an async 'error' EVENT;
15
+ // with no listener attached, that single event kills the whole process — fatal
16
+ // for the shared HTTP transport. Opening the fd first turns the open-failure
17
+ // class (ENOENT/EACCES/...) into a normal rejection the caller's try/catch can
18
+ // map to an error envelope.
19
+ export async function openLocalReadStream(localPath) {
20
+ const handle = await fs.promises.open(localPath, 'r');
21
+ // open() succeeds on a directory; fail it here rather than as an async read error.
22
+ if ((await handle.stat()).isDirectory()) {
23
+ await handle.close();
24
+ throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${localPath}'`), {
25
+ code: 'EISDIR',
26
+ path: localPath,
27
+ });
28
+ }
29
+ const stream = handle.createReadStream();
30
+ // Mid-read errors still reach the consumer through its own listeners; this
31
+ // one only closes the unhandled-'error' crash path.
32
+ stream.on('error', () => { });
33
+ return stream;
34
+ }
@@ -27,6 +27,16 @@ export type AddValidation = {
27
27
  /** Validate the collected form against the alias rules, dup check, and the
28
28
  * bundle catalog. Pure (no I/O) for unit testing. */
29
29
  export declare function validateAddForm(input: Partial<AddForm>, existingAliases: string[]): AddValidation;
30
+ /** Map direct tool arguments onto the elicitation form shape: the argument-
31
+ * mode fallback for clients without form elicitation. All bundle picks travel
32
+ * through otherBundles, which validateAddForm resolves and validates. Pure. */
33
+ export declare function argsToAddForm(a: {
34
+ alias?: string;
35
+ email?: string;
36
+ bundles?: string;
37
+ allBundles?: boolean;
38
+ admin?: boolean;
39
+ }): Partial<AddForm>;
30
40
  /** Scopes requested by the profile but NOT granted at consent (granular
31
41
  * consent / unchecked bundles). Pure. */
32
42
  export declare function scopeGrantDiff(requested: string[], grantedScope: string | undefined): string[];
@@ -5,7 +5,8 @@ import { writeToken } from '../token-store.js';
5
5
  import { resolveScopesForAccount } from '../auth.js';
6
6
  import { BUNDLE_CATALOG, closestBundle, resolveBundleAliases } from '../scope-catalog.js';
7
7
  import { openUrl } from '../open-url.js';
8
- import { buildConsentClient, awaitLoopbackConsent, hasClientCredentials, } from '../oauth-consent.js';
8
+ import { coerceBoolean } from './_coerce.js';
9
+ import { buildConsentClient, openLoopbackConsent, hasClientCredentials, TESTING_MODE_WARNING, } from '../oauth-consent.js';
9
10
  import { detectClients, buildServerEntry, renderInstruction, applyFileEntry, resolveMode, DEFAULT_SERVER_NAME, } from '../client-config.js';
10
11
  // B7: the elicitation-driven account_add / account_reauth wizard. It rebuilds
11
12
  // interactive account management on the mutable config.json registry so a
@@ -70,6 +71,18 @@ export function validateAddForm(input, existingAliases) {
70
71
  }
71
72
  return { ok: true, alias, email, bundles, admin: input.admin === true };
72
73
  }
74
+ /** Map direct tool arguments onto the elicitation form shape: the argument-
75
+ * mode fallback for clients without form elicitation. All bundle picks travel
76
+ * through otherBundles, which validateAddForm resolves and validates. Pure. */
77
+ export function argsToAddForm(a) {
78
+ return {
79
+ alias: a.alias ?? '',
80
+ email: a.email ?? '',
81
+ allBundles: a.allBundles === true,
82
+ otherBundles: a.bundles ?? '',
83
+ admin: a.admin === true,
84
+ };
85
+ }
73
86
  /** Scopes requested by the profile but NOT granted at consent (granular
74
87
  * consent / unchecked bundles). Pure. */
75
88
  export function scopeGrantDiff(requested, grantedScope) {
@@ -103,19 +116,22 @@ async function runConsent(server, alias) {
103
116
  const cfg = getAccountSet().configs[alias];
104
117
  if (!cfg)
105
118
  return { ok: false, text: `E_VALIDATION: account "${alias}" is not in the live registry (env-sourced accounts are not editable here).` };
106
- const client = buildConsentClient();
119
+ // Bind the ephemeral loopback listener BEFORE building the auth URL: the
120
+ // redirect URI needs the assigned port, and listening first means the
121
+ // callback can't race the browser.
122
+ const loop = await openLoopbackConsent();
123
+ const client = buildConsentClient(loop.redirect);
107
124
  const expectedState = randomBytes(32).toString('hex');
108
125
  const scopes = resolveScopesForAccount(alias);
109
126
  const url = client.generateAuthUrl({ access_type: 'offline', prompt: 'consent', scope: scopes, login_hint: cfg.email, state: expectedState });
110
- // Start the loopback listener BEFORE opening the browser so it can't miss the
111
- // redirect. Any startup error (e.g. port in use) surfaces synchronously.
112
- const consent = awaitLoopbackConsent(client, expectedState);
127
+ const consent = loop.finish(client, expectedState);
113
128
  const caps = server.server.getClientCapabilities?.();
114
129
  let opened = false;
115
130
  if (caps?.elicitation?.url) {
116
131
  try {
117
132
  const r = await server.server.elicitInput({ mode: 'url', message: `Authorize the "${alias}" Google account in your browser.`, url });
118
133
  if (r.action !== 'accept') {
134
+ loop.close();
119
135
  return { ok: false, text: 'confirmation_declined: consent was cancelled; the account row was kept but no token was stored (doctor will show it as "missing").' };
120
136
  }
121
137
  opened = true;
@@ -138,9 +154,10 @@ async function runConsent(server, alias) {
138
154
  return { ok: true, missing: scopeGrantDiff(scopes, typeof tokens.scope === 'string' ? tokens.scope : undefined) };
139
155
  }
140
156
  function s4Text(alias, missing) {
141
- if (missing.length === 0)
142
- return `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`;
143
- return `⚠ "${alias}" authenticated, but ${missing.length} requested scope(s) were NOT granted (E_SCOPE_NOT_GRANTED) — you may have unchecked some on the consent screen. Re-run account_reauth to grant them. The account is usable for the granted scopes.`;
157
+ const outcome = missing.length === 0
158
+ ? `✔ "${alias}" authenticated; all requested scopes granted. It is now usable without a restart.`
159
+ : `⚠ "${alias}" authenticated, but ${missing.length} requested scope(s) were NOT granted (E_SCOPE_NOT_GRANTED) — you may have unchecked some on the consent screen. Re-run account_reauth to grant them. The account is usable for the granted scopes.`;
160
+ return `${outcome}\n${TESTING_MODE_WARNING}`;
144
161
  }
145
162
  const REQUIRES_INTERACTION = { 'anthropic/requiresUserInteraction': true };
146
163
  export function registerAccountWizardTools(registry, server) {
@@ -151,9 +168,15 @@ export function registerAccountWizardTools(registry, server) {
151
168
  registerMeta('account_add', {
152
169
  _meta: REQUIRES_INTERACTION,
153
170
  annotations: { openWorldHint: true },
154
- description: 'Add a new Google account interactively: collects alias/email/scope bundles via a form, writes the registry, and runs Google consent in the browser — no file editing or restart needed. Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
155
- inputSchema: {},
156
- }, async () => {
171
+ description: 'Add a new Google account: pass alias + email directly (plus optional bundles/allBundles/admin), or pass nothing for an interactive form where the client supports elicitation. Writes the registry and runs Google consent in the browser — no file editing or restart needed. Requires GOOGLE_CLIENT_ID/SECRET (run the `setup` prompt first if missing).',
172
+ inputSchema: {
173
+ alias: z.string().optional().describe('Account alias (letters, digits, _ or -). Pass with email to add directly, skipping the form.'),
174
+ email: z.string().optional().describe("The account's Google address (used as the login hint)"),
175
+ bundles: z.string().optional().describe('Optional scope bundles, comma-separated (e.g. "forms,chat"); blank = base scopes only'),
176
+ allBundles: coerceBoolean.optional().describe('Grant every optional bundle (biggest consent screen); overrides bundles'),
177
+ admin: coerceBoolean.optional().describe('Grant Workspace admin scopes (super-admin accounts only)'),
178
+ },
179
+ }, async (args) => {
157
180
  try {
158
181
  // Env-sourced registry: GOOGLE_ACCOUNTS is the exclusive source and
159
182
  // config.json accounts are ignored, so a wizard add would be a phantom
@@ -164,16 +187,26 @@ export function registerAccountWizardTools(registry, server) {
164
187
  if (!hasClientCredentials()) {
165
188
  return textResult('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are not set. Run the `setup` prompt (/mcp__google-multi__setup) to create an OAuth client, then set them.', true);
166
189
  }
167
- const caps = server.server.getClientCapabilities?.();
168
- if (!caps?.elicitation?.form) {
169
- return textResult('This client does not support form elicitation. Add the account from the CLI instead: `npx mcp-google-multi account add --alias <alias> --email <email> [--profile a,b] [--admin]`.', true);
190
+ // S1: collect the registry row. Arguments win over the form so the
191
+ // wizard still works in clients without form elicitation (where the
192
+ // interactive path used to dead-end).
193
+ const a = (args ?? {});
194
+ let input;
195
+ if (a.alias?.trim() || a.email?.trim()) {
196
+ input = argsToAddForm(a);
170
197
  }
171
- // S1: collect the registry row.
172
- const form = await server.server.elicitInput({ message: 'Add a Google account', requestedSchema: addFormSchema() });
173
- if (form.action !== 'accept') {
174
- return textResult('confirmation_declined: no account was added.');
198
+ else {
199
+ const caps = server.server.getClientCapabilities?.();
200
+ if (!caps?.elicitation?.form) {
201
+ return textResult('E_NO_FORM_ELICITATION: this client does not support the interactive form. Call account_add again with arguments instead, e.g. {"alias": "work", "email": "you@example.com"} (optional: "bundles" as a comma-separated list, "allBundles": true, "admin": true).', true);
202
+ }
203
+ const form = await server.server.elicitInput({ message: 'Add a Google account', requestedSchema: addFormSchema() });
204
+ if (form.action !== 'accept') {
205
+ return textResult('confirmation_declined: no account was added.');
206
+ }
207
+ input = form.content ?? {};
175
208
  }
176
- const validated = validateAddForm(form.content ?? {}, getAccountSet().aliases);
209
+ const validated = validateAddForm(input, getAccountSet().aliases);
177
210
  if (!validated.ok)
178
211
  return textResult(`${validated.slug}: ${validated.message}`, true);
179
212
  // S2: atomic write + make the alias callable without a restart (BR3).
@@ -0,0 +1,18 @@
1
+ import type { ToolRegistry } from '../registry.js';
2
+ /** Accepts "213025502" or "properties/213025502"; rejects the identifiers
3
+ * people paste by mistake (G-… measurement IDs, UA-… properties) with a
4
+ * pointer to the right one. */
5
+ export declare function normalizeProperty(input: string): {
6
+ name: string;
7
+ } | {
8
+ hint: string;
9
+ };
10
+ /** GA4 report rows ({dimensionValues:[{value}], metricValues:[{value}]}) are
11
+ * verbose; merge each row into one {name: value} object (dimension and metric
12
+ * API names never collide). Shared by runReport and runRealtimeReport. */
13
+ export declare function shapeReport(data: any): Record<string, unknown>;
14
+ export declare function shapeAccountSummaries(data: any): Record<string, unknown>;
15
+ /** Full metadata descriptions run to paragraphs; the first ~160 chars carry
16
+ * the disambiguation the model needs without bloating a ~300-entry list. */
17
+ export declare function shapeMetadata(data: any): Record<string, unknown>;
18
+ export declare function registerAnalyticsTools(server: ToolRegistry): void;
@@ -0,0 +1,279 @@
1
+ import { z } from 'zod';
2
+ import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
3
+ import { analyticsdata as analyticsdataClient } from '@googleapis/analyticsdata';
4
+ import { analyticsadmin as analyticsadminClient } from '@googleapis/analyticsadmin';
5
+ import { accountAliasSchema } from '../accounts.js';
6
+ import { getClient } from '../client.js';
7
+ import { handleGoogleApiError } from './_errors.js';
8
+ const accountEnum = accountAliasSchema.optional();
9
+ /** Accepts "213025502" or "properties/213025502"; rejects the identifiers
10
+ * people paste by mistake (G-… measurement IDs, UA-… properties) with a
11
+ * pointer to the right one. */
12
+ export function normalizeProperty(input) {
13
+ const t = input.trim();
14
+ if (/^properties\/\d+$/.test(t))
15
+ return { name: t };
16
+ if (/^\d+$/.test(t))
17
+ return { name: `properties/${t}` };
18
+ if (/^G-[A-Z0-9]+$/i.test(t)) {
19
+ return {
20
+ hint: `"${t}" is a measurement ID (a web data-stream tag), not a GA4 property ID. ` +
21
+ 'Use the numeric property ID from GA Admin > Property settings, or find it with analytics_account_summaries.',
22
+ };
23
+ }
24
+ if (/^UA-/i.test(t)) {
25
+ return {
26
+ hint: `"${t}" is a Universal Analytics property, which the GA4 APIs cannot query. ` +
27
+ 'Use the numeric ID of a GA4 property (find yours with analytics_account_summaries).',
28
+ };
29
+ }
30
+ return {
31
+ hint: `"${t}" is not a GA4 property reference. Pass the numeric property ID ` +
32
+ '(e.g. "213025502" or "properties/213025502"); find yours with analytics_account_summaries.',
33
+ };
34
+ }
35
+ /** GA4 report rows ({dimensionValues:[{value}], metricValues:[{value}]}) are
36
+ * verbose; merge each row into one {name: value} object (dimension and metric
37
+ * API names never collide). Shared by runReport and runRealtimeReport. */
38
+ export function shapeReport(data) {
39
+ const dimensionHeaders = (data.dimensionHeaders ?? []).map((h) => h.name);
40
+ const metricHeaders = (data.metricHeaders ?? []).map((h) => ({ name: h.name, type: h.type }));
41
+ const mergeRow = (r) => {
42
+ const out = {};
43
+ dimensionHeaders.forEach((name, i) => {
44
+ out[name] = r.dimensionValues?.[i]?.value ?? '';
45
+ });
46
+ metricHeaders.forEach((h, i) => {
47
+ out[h.name] = r.metricValues?.[i]?.value ?? '';
48
+ });
49
+ return out;
50
+ };
51
+ const shaped = {
52
+ rowCount: data.rowCount ?? data.rows?.length ?? 0,
53
+ dimensionHeaders,
54
+ metricHeaders,
55
+ rows: (data.rows ?? []).map(mergeRow),
56
+ };
57
+ if (data.totals?.length)
58
+ shaped.totals = data.totals.map(mergeRow);
59
+ if (data.maximums?.length)
60
+ shaped.maximums = data.maximums.map(mergeRow);
61
+ if (data.minimums?.length)
62
+ shaped.minimums = data.minimums.map(mergeRow);
63
+ if (data.metadata)
64
+ shaped.metadata = data.metadata;
65
+ if (data.propertyQuota)
66
+ shaped.propertyQuota = data.propertyQuota;
67
+ return shaped;
68
+ }
69
+ export function shapeAccountSummaries(data) {
70
+ const accounts = (data.accountSummaries ?? []).map((a) => ({
71
+ account: a.account,
72
+ displayName: a.displayName,
73
+ properties: (a.propertySummaries ?? []).map((p) => ({
74
+ property: p.property,
75
+ displayName: p.displayName,
76
+ ...(p.propertyType && p.propertyType !== 'PROPERTY_TYPE_ORDINARY' ? { propertyType: p.propertyType } : {}),
77
+ })),
78
+ }));
79
+ return { accounts, ...(data.nextPageToken ? { nextPageToken: data.nextPageToken } : {}) };
80
+ }
81
+ const DESCRIPTION_CAP = 160;
82
+ /** Full metadata descriptions run to paragraphs; the first ~160 chars carry
83
+ * the disambiguation the model needs without bloating a ~300-entry list. */
84
+ export function shapeMetadata(data) {
85
+ const cap = (s) => typeof s === 'string' && s.length > DESCRIPTION_CAP ? `${s.slice(0, DESCRIPTION_CAP - 3)}...` : s || undefined;
86
+ return {
87
+ dimensions: (data.dimensions ?? []).map((d) => ({
88
+ apiName: d.apiName,
89
+ uiName: d.uiName,
90
+ category: d.category,
91
+ ...(d.customDefinition ? { custom: true } : {}),
92
+ description: cap(d.description),
93
+ })),
94
+ metrics: (data.metrics ?? []).map((m) => ({
95
+ apiName: m.apiName,
96
+ uiName: m.uiName,
97
+ category: m.category,
98
+ ...(m.type ? { type: m.type } : {}),
99
+ ...(m.expression ? { expression: m.expression } : {}),
100
+ ...(m.customDefinition ? { custom: true } : {}),
101
+ description: cap(m.description),
102
+ })),
103
+ };
104
+ }
105
+ const propertySchema = z
106
+ .string()
107
+ .describe('GA4 property: numeric ID like "213025502" or "properties/213025502" — NOT a "G-..." measurement ID and not "UA-...". Find yours with analytics_account_summaries.');
108
+ export function registerAnalyticsTools(server) {
109
+ server.registerTool('analytics_account_summaries', {
110
+ description: 'List every Google Analytics (GA4) account and property this Google account can access, with their numeric property IDs. The starting point for any Analytics question ("what properties do I have?").',
111
+ inputSchema: {
112
+ account: accountEnum.describe('Google account alias'),
113
+ pageSize: z.number().min(1).max(200).optional().describe('Summaries per page (default 50, max 200)'),
114
+ pageToken: z.string().optional().describe('Token from a previous page'),
115
+ },
116
+ }, async ({ account, pageSize, pageToken }) => {
117
+ try {
118
+ const auth = await getClient(account);
119
+ const admin = analyticsadminClient({ version: 'v1beta', auth });
120
+ const res = await admin.accountSummaries.list({ pageSize, pageToken });
121
+ return {
122
+ content: [{ type: 'text', text: JSON.stringify(shapeAccountSummaries(res.data), null, 2) }],
123
+ };
124
+ }
125
+ catch (error) {
126
+ return handleAnalyticsError(error, account);
127
+ }
128
+ });
129
+ server.registerTool('analytics_run_report', {
130
+ description: 'Run a Google Analytics (GA4) report: metrics over a date range, optionally grouped by dimensions — the workhorse for questions like "how many users last week, by country". Dates accept YYYY-MM-DD or relative forms ("today", "yesterday", "28daysAgo"). Unsure which dimension/metric names are valid? Call analytics_get_metadata first.',
131
+ inputSchema: {
132
+ account: accountEnum.describe('Google account alias'),
133
+ property: propertySchema,
134
+ startDate: z.string().describe('Start date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo" e.g. "28daysAgo")'),
135
+ endDate: z.string().describe('End date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo")'),
136
+ metrics: coerceArray(z.string()).describe('Metric API names, e.g. ["activeUsers","sessions","screenPageViews"]. Max 10. Valid names (including custom ones) come from analytics_get_metadata.'),
137
+ dimensions: coerceArray(z.string())
138
+ .optional()
139
+ .describe('Dimension API names to group by, e.g. ["date"] or ["country","deviceCategory"]. Max 9. Omit for a single total row.'),
140
+ dimensionFilter: coerceJson(z.record(z.string(), z.unknown()))
141
+ .optional()
142
+ .describe('FilterExpression on dimensions (applies independently of metricFilter). Simple: {"filter":{"fieldName":"country","stringFilter":{"matchType":"EXACT","value":"France"}}}. AND of two: {"andGroup":{"expressions":[{"filter":{"fieldName":"country","stringFilter":{"matchType":"EXACT","value":"France"}}},{"filter":{"fieldName":"deviceCategory","stringFilter":{"matchType":"EXACT","value":"mobile"}}}]}}. matchType: EXACT | BEGINS_WITH | ENDS_WITH | CONTAINS | FULL_REGEXP (add "caseSensitive":true for case-sensitive). Also available: inListFilter, notExpression, orGroup.'),
143
+ metricFilter: coerceJson(z.record(z.string(), z.unknown()))
144
+ .optional()
145
+ .describe('FilterExpression on metric values, e.g. {"filter":{"fieldName":"sessions","numericFilter":{"operation":"GREATER_THAN","value":{"int64Value":"100"}}}}. operation: EQUAL | LESS_THAN | LESS_THAN_OR_EQUAL | GREATER_THAN | GREATER_THAN_OR_EQUAL; betweenFilter takes fromValue/toValue.'),
146
+ orderBys: coerceJson(z.array(z.record(z.string(), z.unknown())))
147
+ .optional()
148
+ .describe('Sort order, e.g. [{"metric":{"metricName":"sessions"},"desc":true}] or [{"dimension":{"dimensionName":"date"}}]. Default: unordered.'),
149
+ limit: z.number().min(1).max(250000).optional().describe('Max rows to return (API default 10000). Keep small for readable output.'),
150
+ offset: z.number().min(0).optional().describe('Zero-based row offset for pagination'),
151
+ metricAggregations: coerceArray(z.enum(['TOTAL', 'MINIMUM', 'MAXIMUM', 'COUNT']))
152
+ .optional()
153
+ .describe('Also return aggregate rows across all matching data (surfaced as totals/maximums/minimums)'),
154
+ keepEmptyRows: coerceBoolean.optional().describe('Include rows whose metrics are all zero (default false)'),
155
+ returnPropertyQuota: coerceBoolean.optional().describe("Include this property's remaining quota tokens in the response"),
156
+ },
157
+ }, async ({ account, property, startDate, endDate, metrics, dimensions, dimensionFilter, metricFilter, orderBys, limit, offset, metricAggregations, keepEmptyRows, returnPropertyQuota }) => {
158
+ const prop = normalizeProperty(property);
159
+ if ('hint' in prop)
160
+ return invalidProperty(prop.hint, account);
161
+ try {
162
+ const auth = await getClient(account);
163
+ const dataApi = analyticsdataClient({ version: 'v1beta', auth });
164
+ const requestBody = {
165
+ dateRanges: [{ startDate, endDate }],
166
+ metrics: metrics.map((name) => ({ name })),
167
+ };
168
+ if (dimensions?.length)
169
+ requestBody.dimensions = dimensions.map((name) => ({ name }));
170
+ if (dimensionFilter)
171
+ requestBody.dimensionFilter = dimensionFilter;
172
+ if (metricFilter)
173
+ requestBody.metricFilter = metricFilter;
174
+ if (orderBys?.length)
175
+ requestBody.orderBys = orderBys;
176
+ if (limit !== undefined)
177
+ requestBody.limit = limit;
178
+ if (offset !== undefined)
179
+ requestBody.offset = offset;
180
+ if (metricAggregations?.length)
181
+ requestBody.metricAggregations = metricAggregations;
182
+ if (keepEmptyRows !== undefined)
183
+ requestBody.keepEmptyRows = keepEmptyRows;
184
+ if (returnPropertyQuota !== undefined)
185
+ requestBody.returnPropertyQuota = returnPropertyQuota;
186
+ const res = await dataApi.properties.runReport({ property: prop.name, requestBody });
187
+ return {
188
+ content: [{ type: 'text', text: JSON.stringify(shapeReport(res.data), null, 2) }],
189
+ };
190
+ }
191
+ catch (error) {
192
+ return handleAnalyticsError(error, account);
193
+ }
194
+ });
195
+ server.registerTool('analytics_run_realtime_report', {
196
+ description: 'Run a GA4 realtime report: who is on the site right now (last 30 minutes). Realtime supports a restricted set of names, e.g. metrics activeUsers, screenPageViews, eventCount, keyEvents; dimensions country, city, deviceCategory, unifiedScreenName, eventName.',
197
+ inputSchema: {
198
+ account: accountEnum.describe('Google account alias'),
199
+ property: propertySchema,
200
+ metrics: coerceArray(z.string()).describe('Realtime metric API names, e.g. ["activeUsers"]'),
201
+ dimensions: coerceArray(z.string()).optional().describe('Realtime dimension API names, e.g. ["country"] or ["unifiedScreenName"]'),
202
+ dimensionFilter: coerceJson(z.record(z.string(), z.unknown()))
203
+ .optional()
204
+ .describe('FilterExpression on dimensions — same shape as analytics_run_report'),
205
+ metricFilter: coerceJson(z.record(z.string(), z.unknown()))
206
+ .optional()
207
+ .describe('FilterExpression on metric values — same shape as analytics_run_report'),
208
+ minuteRanges: coerceJson(z.array(z.record(z.string(), z.unknown())))
209
+ .optional()
210
+ .describe('Up to 2 ranges of minutes-ago, e.g. [{"startMinutesAgo":29,"endMinutesAgo":0}] (default: last 30 minutes)'),
211
+ limit: z.number().min(1).max(250000).optional().describe('Max rows to return'),
212
+ returnPropertyQuota: coerceBoolean.optional().describe("Include this property's remaining realtime quota tokens"),
213
+ },
214
+ }, async ({ account, property, metrics, dimensions, dimensionFilter, metricFilter, minuteRanges, limit, returnPropertyQuota }) => {
215
+ const prop = normalizeProperty(property);
216
+ if ('hint' in prop)
217
+ return invalidProperty(prop.hint, account);
218
+ try {
219
+ const auth = await getClient(account);
220
+ const dataApi = analyticsdataClient({ version: 'v1beta', auth });
221
+ const requestBody = { metrics: metrics.map((name) => ({ name })) };
222
+ if (dimensions?.length)
223
+ requestBody.dimensions = dimensions.map((name) => ({ name }));
224
+ if (dimensionFilter)
225
+ requestBody.dimensionFilter = dimensionFilter;
226
+ if (metricFilter)
227
+ requestBody.metricFilter = metricFilter;
228
+ if (minuteRanges?.length)
229
+ requestBody.minuteRanges = minuteRanges;
230
+ if (limit !== undefined)
231
+ requestBody.limit = limit;
232
+ if (returnPropertyQuota !== undefined)
233
+ requestBody.returnPropertyQuota = returnPropertyQuota;
234
+ const res = await dataApi.properties.runRealtimeReport({ property: prop.name, requestBody });
235
+ return {
236
+ content: [{ type: 'text', text: JSON.stringify(shapeReport(res.data), null, 2) }],
237
+ };
238
+ }
239
+ catch (error) {
240
+ return handleAnalyticsError(error, account);
241
+ }
242
+ });
243
+ server.registerTool('analytics_get_metadata', {
244
+ description: 'List every valid dimension and metric API name for a GA4 property, including its custom definitions. Call this before analytics_run_report when unsure which names exist. Property "0" returns the standard set without property access.',
245
+ inputSchema: {
246
+ account: accountEnum.describe('Google account alias'),
247
+ property: propertySchema,
248
+ },
249
+ }, async ({ account, property }) => {
250
+ const prop = normalizeProperty(property);
251
+ if ('hint' in prop)
252
+ return invalidProperty(prop.hint, account);
253
+ try {
254
+ const auth = await getClient(account);
255
+ const dataApi = analyticsdataClient({ version: 'v1beta', auth });
256
+ const res = await dataApi.properties.getMetadata({ name: `${prop.name}/metadata` });
257
+ return {
258
+ content: [{ type: 'text', text: JSON.stringify(shapeMetadata(res.data), null, 2) }],
259
+ };
260
+ }
261
+ catch (error) {
262
+ return handleAnalyticsError(error, account);
263
+ }
264
+ });
265
+ }
266
+ function invalidProperty(hint, account) {
267
+ return {
268
+ content: [
269
+ {
270
+ type: 'text',
271
+ text: JSON.stringify({ error: 'invalid_params', message: 'Invalid GA4 property reference.', hint, retriable: false, account }),
272
+ },
273
+ ],
274
+ isError: true,
275
+ };
276
+ }
277
+ function handleAnalyticsError(error, account) {
278
+ return handleGoogleApiError(error, account, 'Needs the "analytics" bundle on this account (add it to the scope profile, then re-auth), and the Google account must have access to this GA4 property.');
279
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
- export declare function prepareLocalDest(savePath: string, filename: string): string;
2
+ export declare function isTextualMime(mimeType: string): boolean;
3
+ export declare function resolveConvertTarget(convertTo: string | undefined): string | undefined;
3
4
  export declare const DRIVE_QUERY_HINT: string;
4
5
  export declare function normalizeDriveQuery(raw: string): string;
5
6
  export declare function isDriveInvalidQuery(error: any): boolean;