mcp-google-multi 6.0.0-alpha.11 → 6.0.0-alpha.13
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 +2 -2
- package/dist/api-probe.js +3 -0
- package/dist/discovery-client.js +8 -2
- package/dist/scope-catalog.js +7 -0
- package/dist/services.js +3 -1
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +279 -0
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +884 -0
- package/dist/tools/generated/cloudsearch.js +1 -1
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/google-api.js +2 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
7
|
-
- 🧰 **Exhaustive** —
|
|
7
|
+
- 🧰 **Exhaustive** — 940 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
|
|
8
8
|
- 🔑 **Multi-account** — drive any number of Google accounts by alias, or fan one call out across all of them
|
|
9
9
|
- 🔒 **Private by design** — your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering — it talks only to Google
|
|
10
|
-
- 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI) → [remote setup](./docs/http-setup.md)
|
|
10
|
+
- 🌐 **Local or remote** — runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS → [remote setup](./docs/http-setup.md)
|
|
11
11
|
- ✉️ **Built for real work** — send and read email in Markdown with attachments and one-call replies, an interactive setup wizard with a `doctor` self-check, and per-account scope profiles → [features tour](./docs/features.md)
|
|
12
12
|
|
|
13
13
|
## Quick setup
|
package/dist/api-probe.js
CHANGED
|
@@ -17,6 +17,9 @@ export const API_PROBES = [
|
|
|
17
17
|
{ service: 'chat', api: 'chat', url: 'https://chat.googleapis.com/v1/spaces?pageSize=1', scopePrefixes: [`${P}chat.`] },
|
|
18
18
|
{ service: 'meet', api: 'meet', url: 'https://meet.googleapis.com/v2/conferenceRecords?pageSize=1', scopePrefixes: [`${P}meetings.`] },
|
|
19
19
|
{ service: 'forms', api: 'forms', url: `https://forms.googleapis.com/v1/forms/${BOGUS_ID}`, scopePrefixes: [`${P}forms.`], notFoundMeansEnabled: true },
|
|
20
|
+
// Probes the Admin API only: the Data API has no no-arg read (every call
|
|
21
|
+
// needs a property id), so its enablement surfaces on first report instead.
|
|
22
|
+
{ service: 'analytics', api: 'analyticsadmin', url: 'https://analyticsadmin.googleapis.com/v1beta/accountSummaries?pageSize=1', scopePrefixes: [`${P}analytics`] },
|
|
20
23
|
];
|
|
21
24
|
export function planProbes(granted, probes = API_PROBES) {
|
|
22
25
|
return probes.filter((p) => granted.some((s) => p.scopePrefixes.some((prefix) => s.startsWith(prefix))));
|
package/dist/discovery-client.js
CHANGED
|
@@ -20,6 +20,8 @@ export const WORKSPACE_APIS = {
|
|
|
20
20
|
admin_reports: { id: 'admin', version: 'reports_v1' },
|
|
21
21
|
admin_datatransfer: { id: 'admin', version: 'datatransfer_v1' },
|
|
22
22
|
groupssettings: { id: 'groupssettings', version: 'v1' },
|
|
23
|
+
analyticsadmin: { id: 'analyticsadmin', version: 'v1beta' },
|
|
24
|
+
analyticsdata: { id: 'analyticsdata', version: 'v1beta' },
|
|
23
25
|
appsmarket: { id: 'appsmarket', version: 'v2' },
|
|
24
26
|
classroom: { id: 'classroom', version: 'v1' },
|
|
25
27
|
cloudidentity: { id: 'cloudidentity', version: 'v1' },
|
|
@@ -148,9 +150,13 @@ export async function loadMethodIndex(api, deps = {}) {
|
|
|
148
150
|
export function clearDiscoveryMemoryCache() {
|
|
149
151
|
memoryCache.clear();
|
|
150
152
|
}
|
|
151
|
-
|
|
153
|
+
// GA4-style report execution (runReport, batchRunPivotReports, runAccessReport)
|
|
154
|
+
// and check* predicates are POSTs purely for the request-body size — reads.
|
|
155
|
+
const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect|check|(batch)?run\w*report)/i;
|
|
152
156
|
const POST_UPDATE_VERB = /^(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format)/i;
|
|
153
|
-
|
|
157
|
+
// archive sits with the deletes: in GA4 archiving a custom dimension/metric is
|
|
158
|
+
// permanent, so the most restrictive write class is the safe classification.
|
|
159
|
+
const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout|archive)/i;
|
|
154
160
|
export function cudFromMethod(method) {
|
|
155
161
|
switch (method.httpMethod) {
|
|
156
162
|
case 'GET':
|
package/dist/scope-catalog.js
CHANGED
|
@@ -108,6 +108,13 @@ export const BUNDLE_CATALOG = {
|
|
|
108
108
|
description: 'Read Gmail Postmaster Tools deliverability data.',
|
|
109
109
|
risk: 'low',
|
|
110
110
|
},
|
|
111
|
+
// Read-only by design: GA4 admin writes need analytics.edit, which ships as
|
|
112
|
+
// a separate opt-in bundle only if real demand appears (plan 6.0.0 GA-1).
|
|
113
|
+
analytics: {
|
|
114
|
+
scopes: ['https://www.googleapis.com/auth/analytics.readonly'],
|
|
115
|
+
description: 'Read Google Analytics (GA4): run reports and inspect accounts, properties and their configuration.',
|
|
116
|
+
risk: 'low',
|
|
117
|
+
},
|
|
111
118
|
groupssettings: {
|
|
112
119
|
scopes: ['https://www.googleapis.com/auth/apps.groups.settings'],
|
|
113
120
|
description: 'Change Google Groups settings for the domain.',
|
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: () => new Set(getOptionalBundles()).has('analytics') },
|
|
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),
|
|
@@ -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
|
+
}
|