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/tools/_errors.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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,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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
155
|
-
inputSchema: {
|
|
156
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(
|
|
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
|
+
}
|
package/dist/tools/drive.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
|
-
export declare function
|
|
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;
|