mcp-google-multi 6.0.0-beta.1 → 6.0.0-beta.2
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/accounts.d.ts +5 -0
- package/dist/accounts.js +12 -1
- package/dist/api-probe.js +3 -0
- package/dist/arg-normalize.d.ts +61 -4
- package/dist/arg-normalize.js +228 -4
- package/dist/arg-strict.d.ts +54 -0
- package/dist/arg-strict.js +163 -0
- package/dist/auth.js +15 -72
- package/dist/client.js +15 -5
- package/dist/config-file.d.ts +1 -0
- package/dist/config-file.js +4 -0
- package/dist/discover.js +35 -14
- package/dist/discovery-client.d.ts +8 -1
- package/dist/discovery-client.js +96 -17
- package/dist/doctor.d.ts +9 -1
- package/dist/doctor.js +32 -4
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +23 -1
- package/dist/executor.js +12 -1
- package/dist/fanout.js +43 -8
- package/dist/http-transport.d.ts +11 -2
- package/dist/http-transport.js +10 -5
- package/dist/index.js +112 -12
- package/dist/metrics-cli.d.ts +21 -0
- package/dist/metrics-cli.js +206 -0
- package/dist/metrics-tap.d.ts +12 -0
- package/dist/metrics-tap.js +95 -0
- package/dist/oauth-consent.d.ts +25 -10
- package/dist/oauth-consent.js +85 -39
- package/dist/outbound-allowlist.d.ts +34 -0
- package/dist/outbound-allowlist.js +108 -0
- package/dist/registry.d.ts +49 -2
- package/dist/registry.js +147 -7
- package/dist/scope-catalog.js +16 -0
- package/dist/services.d.ts +8 -0
- package/dist/services.js +28 -1
- package/dist/setup-prompt.d.ts +1 -1
- package/dist/setup-prompt.js +1 -1
- package/dist/tools/_coerce.d.ts +5 -0
- package/dist/tools/_coerce.js +11 -0
- package/dist/tools/_errors.d.ts +31 -3
- package/dist/tools/_errors.js +307 -43
- package/dist/tools/_local-files.js +7 -2
- package/dist/tools/account-wizard.d.ts +24 -1
- package/dist/tools/account-wizard.js +152 -51
- package/dist/tools/admin.js +7 -6
- package/dist/tools/analytics.d.ts +18 -0
- package/dist/tools/analytics.js +284 -0
- package/dist/tools/calendar.d.ts +7 -0
- package/dist/tools/calendar.js +75 -15
- package/dist/tools/chat.js +7 -4
- package/dist/tools/contacts.js +57 -30
- package/dist/tools/docs.js +56 -54
- package/dist/tools/drive.js +132 -70
- package/dist/tools/forms.js +7 -7
- package/dist/tools/generated/_shared.d.ts +6 -0
- package/dist/tools/generated/_shared.js +11 -1
- package/dist/tools/generated/admin.js +345 -214
- package/dist/tools/generated/analytics.d.ts +2 -0
- package/dist/tools/generated/analytics.js +981 -0
- package/dist/tools/generated/appsmarket.js +4 -4
- package/dist/tools/generated/calendar.js +28 -28
- package/dist/tools/generated/chat.js +83 -56
- package/dist/tools/generated/classroom.js +258 -216
- package/dist/tools/generated/cloudidentity.js +76 -68
- package/dist/tools/generated/cloudsearch.js +40 -39
- package/dist/tools/generated/contacts.js +20 -11
- package/dist/tools/generated/drive.js +62 -53
- package/dist/tools/generated/drivelabels.js +60 -32
- package/dist/tools/generated/forms.js +7 -7
- package/dist/tools/generated/gmail.js +136 -109
- package/dist/tools/generated/groupsmigration.js +1 -1
- package/dist/tools/generated/groupssettings.js +3 -3
- package/dist/tools/generated/index.js +2 -0
- package/dist/tools/generated/keep.js +8 -7
- package/dist/tools/generated/licensing.js +39 -23
- package/dist/tools/generated/meet.js +14 -14
- package/dist/tools/generated/method-map.d.ts +2 -0
- package/dist/tools/generated/method-map.js +894 -0
- package/dist/tools/generated/postmaster.js +3 -3
- package/dist/tools/generated/reseller.js +34 -28
- package/dist/tools/generated/script.js +29 -19
- package/dist/tools/generated/searchconsole.js +5 -3
- package/dist/tools/generated/sheets.js +10 -9
- package/dist/tools/generated/tasks.js +10 -4
- package/dist/tools/generated/vault.js +65 -56
- package/dist/tools/generated/workspaceevents.js +17 -16
- package/dist/tools/gmail.d.ts +10 -1
- package/dist/tools/gmail.js +50 -18
- package/dist/tools/google-api.js +48 -19
- package/dist/tools/meet.js +5 -2
- package/dist/tools/searchconsole.js +10 -10
- package/dist/tools/sheets.js +36 -48
- package/dist/tools/slides.js +6 -6
- package/dist/tools/tasks.js +16 -16
- package/dist/trim.d.ts +21 -0
- package/dist/trim.js +47 -0
- package/dist/usage-metrics.d.ts +155 -0
- package/dist/usage-metrics.js +603 -0
- package/dist/write-control.d.ts +27 -1
- package/dist/write-control.js +90 -11
- package/package.json +8 -3
|
@@ -0,0 +1,284 @@
|
|
|
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, {
|
|
279
|
+
scope: 'Needs the "analytics" bundle on this account: add it to the scope profile, then re-auth.',
|
|
280
|
+
// GA4 answers PERMISSION_DENIED for a property ACL too, and the old
|
|
281
|
+
// combined hint sent those callers to re-auth a scope they already had.
|
|
282
|
+
resource: 'The scope is not the problem: this Google account has no access to that GA4 property. Grant it in GA4 Admin > Property Access Management, or call analytics_account_summaries to see the properties it can read.',
|
|
283
|
+
});
|
|
284
|
+
}
|
package/dist/tools/calendar.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { ToolRegistry } from '../registry.js';
|
|
2
2
|
export declare function registerCalendarTools(server: ToolRegistry): void;
|
|
3
|
+
/** Google disagrees with itself on a backwards window: events.list answers 200
|
|
4
|
+
* with [], which reads as "nothing scheduled", while freebusy.query 400s on the
|
|
5
|
+
* identical input. Decide locally so an empty list always means empty. Returns
|
|
6
|
+
* null when either bound is absent or unparseable: those stay Google's call.
|
|
7
|
+
* Date.parse is lenient, so it is used only to SKIP the check, never to accept. */
|
|
8
|
+
export declare function timeRangeError(timeMin: string | undefined, timeMax: string | undefined): string | null;
|
|
9
|
+
export declare const TIME_RANGE_HINT: string;
|
|
3
10
|
export declare function formatEvent(event: any, opts?: {
|
|
4
11
|
full?: boolean;
|
|
5
12
|
}): {
|
package/dist/tools/calendar.js
CHANGED
|
@@ -3,8 +3,9 @@ import { coerceArray, coerceBoolean } from './_coerce.js';
|
|
|
3
3
|
import { calendar as calendarClient } from '@googleapis/calendar';
|
|
4
4
|
import { accountAliasSchema } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { checkOutbound } from '../outbound-allowlist.js';
|
|
7
|
+
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
8
|
+
import { listResult, sliceClean } from '../trim.js';
|
|
8
9
|
const accountEnum = accountAliasSchema.optional();
|
|
9
10
|
export function registerCalendarTools(server) {
|
|
10
11
|
server.registerTool('calendar_list_calendars', {
|
|
@@ -46,8 +47,13 @@ export function registerCalendarTools(server) {
|
|
|
46
47
|
.describe('End of time range (ISO 8601)'),
|
|
47
48
|
maxResults: z.number().min(1).max(250).default(25).optional()
|
|
48
49
|
.describe('Max events to return (default: 25)'),
|
|
50
|
+
pageToken: z.string().min(1).optional()
|
|
51
|
+
.describe('Continuation token from a previous call\'s nextPageToken'),
|
|
49
52
|
},
|
|
50
|
-
}, async ({ account, calendarId, query, timeMin, timeMax, maxResults }) => {
|
|
53
|
+
}, async ({ account, calendarId, query, timeMin, timeMax, maxResults, pageToken }) => {
|
|
54
|
+
const rangeError = timeRangeError(timeMin, timeMax);
|
|
55
|
+
if (rangeError)
|
|
56
|
+
return invalidParams(account, rangeError, TIME_RANGE_HINT);
|
|
51
57
|
try {
|
|
52
58
|
const auth = await getClient(account);
|
|
53
59
|
const cal = calendarClient({ version: 'v3', auth });
|
|
@@ -63,13 +69,19 @@ export function registerCalendarTools(server) {
|
|
|
63
69
|
params.timeMin = timeMin;
|
|
64
70
|
if (timeMax)
|
|
65
71
|
params.timeMax = timeMax;
|
|
66
|
-
if (
|
|
72
|
+
if (pageToken)
|
|
73
|
+
params.pageToken = pageToken;
|
|
74
|
+
// A page token is only valid for the window that produced it, so
|
|
75
|
+
// re-evaluating the "now" default would fetch page 2 of a different query.
|
|
76
|
+
if (!timeMin && !timeMax && !pageToken) {
|
|
67
77
|
params.timeMin = new Date().toISOString();
|
|
68
78
|
}
|
|
69
79
|
const res = await cal.events.list(params);
|
|
70
80
|
const events = (res.data.items ?? []).map((e) => formatEvent(e, { full: false }));
|
|
71
81
|
return {
|
|
72
|
-
content: [{ type: 'text', text: JSON.stringify(events,
|
|
82
|
+
content: [{ type: 'text', text: JSON.stringify(listResult('events', events, {
|
|
83
|
+
nextPageToken: res.data.nextPageToken,
|
|
84
|
+
}), null, 2) }],
|
|
73
85
|
};
|
|
74
86
|
}
|
|
75
87
|
catch (error) {
|
|
@@ -80,7 +92,7 @@ export function registerCalendarTools(server) {
|
|
|
80
92
|
description: 'Get a single Google Calendar event by ID',
|
|
81
93
|
inputSchema: {
|
|
82
94
|
account: accountEnum.describe('Google account alias'),
|
|
83
|
-
eventId: z.string().describe('Calendar event ID'),
|
|
95
|
+
eventId: z.string().min(1).describe('Calendar event ID'),
|
|
84
96
|
calendarId: z.string().default('primary').optional()
|
|
85
97
|
.describe('Calendar ID (default: primary)'),
|
|
86
98
|
},
|
|
@@ -118,6 +130,11 @@ export function registerCalendarTools(server) {
|
|
|
118
130
|
},
|
|
119
131
|
}, async ({ account, summary, start, end, description, location, attendees, calendarId, allDay }) => {
|
|
120
132
|
try {
|
|
133
|
+
if (attendees) {
|
|
134
|
+
const outbound = checkOutbound('calendar attendee', attendees.split(','), String(account));
|
|
135
|
+
if (outbound)
|
|
136
|
+
return outbound;
|
|
137
|
+
}
|
|
121
138
|
const auth = await getClient(account);
|
|
122
139
|
const cal = calendarClient({ version: 'v3', auth });
|
|
123
140
|
const event = { summary };
|
|
@@ -155,7 +172,7 @@ export function registerCalendarTools(server) {
|
|
|
155
172
|
description: 'Update a Google Calendar event',
|
|
156
173
|
inputSchema: {
|
|
157
174
|
account: accountEnum.describe('Google account alias'),
|
|
158
|
-
eventId: z.string().describe('Calendar event ID'),
|
|
175
|
+
eventId: z.string().min(1).describe('Calendar event ID'),
|
|
159
176
|
summary: z.string().optional().describe('New event title'),
|
|
160
177
|
start: z.string().optional().describe('New start time (ISO 8601)'),
|
|
161
178
|
end: z.string().optional().describe('New end time (ISO 8601)'),
|
|
@@ -168,6 +185,11 @@ export function registerCalendarTools(server) {
|
|
|
168
185
|
},
|
|
169
186
|
}, async ({ account, eventId, summary, start, end, description, location, attendees, calendarId }) => {
|
|
170
187
|
try {
|
|
188
|
+
if (attendees) {
|
|
189
|
+
const outbound = checkOutbound('calendar attendee', attendees.split(','), String(account));
|
|
190
|
+
if (outbound)
|
|
191
|
+
return outbound;
|
|
192
|
+
}
|
|
171
193
|
const auth = await getClient(account);
|
|
172
194
|
const cal = calendarClient({ version: 'v3', auth });
|
|
173
195
|
// Fetch the event first so a 404 surfaces before we attempt the patch.
|
|
@@ -215,7 +237,7 @@ export function registerCalendarTools(server) {
|
|
|
215
237
|
description: 'Delete a Google Calendar event',
|
|
216
238
|
inputSchema: {
|
|
217
239
|
account: accountEnum.describe('Google account alias'),
|
|
218
|
-
eventId: z.string().describe('Calendar event ID'),
|
|
240
|
+
eventId: z.string().min(1).describe('Calendar event ID'),
|
|
219
241
|
calendarId: z.string().default('primary').optional()
|
|
220
242
|
.describe('Calendar ID (default: primary)'),
|
|
221
243
|
},
|
|
@@ -265,9 +287,9 @@ export function registerCalendarTools(server) {
|
|
|
265
287
|
description: 'Move an event from one calendar to another',
|
|
266
288
|
inputSchema: {
|
|
267
289
|
account: accountEnum.describe('Google account alias'),
|
|
268
|
-
calendarId: z.string().describe('Source calendar ID'),
|
|
269
|
-
eventId: z.string().describe('Event ID to move'),
|
|
270
|
-
destinationCalendarId: z.string().describe('Destination calendar ID'),
|
|
290
|
+
calendarId: z.string().min(1).describe('Source calendar ID'),
|
|
291
|
+
eventId: z.string().min(1).describe('Event ID to move'),
|
|
292
|
+
destinationCalendarId: z.string().min(1).describe('Destination calendar ID'),
|
|
271
293
|
sendNotifications: coerceBoolean.optional().describe('Send notifications (default: false)'),
|
|
272
294
|
},
|
|
273
295
|
}, async ({ account, calendarId, eventId, destinationCalendarId, sendNotifications }) => {
|
|
@@ -294,13 +316,18 @@ export function registerCalendarTools(server) {
|
|
|
294
316
|
account: accountEnum.describe('Google account alias'),
|
|
295
317
|
calendarId: z.string().default('primary').optional()
|
|
296
318
|
.describe('Calendar ID (default: primary)'),
|
|
297
|
-
eventId: z.string().describe('ID of the recurring event series'),
|
|
319
|
+
eventId: z.string().min(1).describe('ID of the recurring event series'),
|
|
298
320
|
timeMin: z.string().optional().describe('ISO 8601 — filter instances after this time'),
|
|
299
321
|
timeMax: z.string().optional().describe('ISO 8601 — filter instances before this time'),
|
|
300
322
|
maxResults: z.number().min(1).max(250).default(25).optional()
|
|
301
323
|
.describe('Max instances to return (default: 25)'),
|
|
324
|
+
pageToken: z.string().min(1).optional()
|
|
325
|
+
.describe('Continuation token from a previous call\'s nextPageToken'),
|
|
302
326
|
},
|
|
303
|
-
}, async ({ account, calendarId, eventId, timeMin, timeMax, maxResults }) => {
|
|
327
|
+
}, async ({ account, calendarId, eventId, timeMin, timeMax, maxResults, pageToken }) => {
|
|
328
|
+
const rangeError = timeRangeError(timeMin, timeMax);
|
|
329
|
+
if (rangeError)
|
|
330
|
+
return invalidParams(account, rangeError, TIME_RANGE_HINT);
|
|
304
331
|
try {
|
|
305
332
|
const auth = await getClient(account);
|
|
306
333
|
const cal = calendarClient({ version: 'v3', auth });
|
|
@@ -310,10 +337,13 @@ export function registerCalendarTools(server) {
|
|
|
310
337
|
timeMin,
|
|
311
338
|
timeMax,
|
|
312
339
|
maxResults: maxResults ?? 25,
|
|
340
|
+
...(pageToken ? { pageToken } : {}),
|
|
313
341
|
});
|
|
314
342
|
const events = (res.data.items ?? []).map((e) => formatEvent(e, { full: false }));
|
|
315
343
|
return {
|
|
316
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
344
|
+
content: [{ type: 'text', text: JSON.stringify(listResult('instances', events, {
|
|
345
|
+
nextPageToken: res.data.nextPageToken,
|
|
346
|
+
}), null, 2) }],
|
|
317
347
|
};
|
|
318
348
|
}
|
|
319
349
|
catch (error) {
|
|
@@ -331,6 +361,12 @@ export function registerCalendarTools(server) {
|
|
|
331
361
|
},
|
|
332
362
|
}, async ({ account, calendarIds, timeMin, timeMax, timeZone }) => {
|
|
333
363
|
try {
|
|
364
|
+
if (calendarIds.length === 0) {
|
|
365
|
+
return invalidParams(account, '`calendarIds` is empty, so there is nothing to check.', 'Pass at least one calendar ID, e.g. ["primary"]. Use calendar_list_calendars to see the IDs this account can read.');
|
|
366
|
+
}
|
|
367
|
+
const rangeError = timeRangeError(timeMin, timeMax);
|
|
368
|
+
if (rangeError)
|
|
369
|
+
return invalidParams(account, rangeError, TIME_RANGE_HINT);
|
|
334
370
|
const auth = await getClient(account);
|
|
335
371
|
const cal = calendarClient({ version: 'v3', auth });
|
|
336
372
|
const res = await cal.freebusy.query({
|
|
@@ -342,7 +378,11 @@ export function registerCalendarTools(server) {
|
|
|
342
378
|
},
|
|
343
379
|
});
|
|
344
380
|
return {
|
|
345
|
-
|
|
381
|
+
// freebusy answers 200 with NO `calendars` key on an empty items
|
|
382
|
+
// list, and JSON.stringify(undefined) returns undefined, not a
|
|
383
|
+
// string: the SDK then rejected our own result with -32602. The
|
|
384
|
+
// value is a map keyed by calendar id, so the fallback is {}.
|
|
385
|
+
content: [{ type: 'text', text: JSON.stringify(res.data.calendars ?? {}, null, 2) }],
|
|
346
386
|
};
|
|
347
387
|
}
|
|
348
388
|
catch (error) {
|
|
@@ -379,6 +419,26 @@ export function registerCalendarTools(server) {
|
|
|
379
419
|
}
|
|
380
420
|
const LIST_DESCRIPTION_CAP = 300;
|
|
381
421
|
const TRUNCATION_MARKER = '… [truncated, use calendar_get_event]';
|
|
422
|
+
/** Google disagrees with itself on a backwards window: events.list answers 200
|
|
423
|
+
* with [], which reads as "nothing scheduled", while freebusy.query 400s on the
|
|
424
|
+
* identical input. Decide locally so an empty list always means empty. Returns
|
|
425
|
+
* null when either bound is absent or unparseable: those stay Google's call.
|
|
426
|
+
* Date.parse is lenient, so it is used only to SKIP the check, never to accept. */
|
|
427
|
+
export function timeRangeError(timeMin, timeMax) {
|
|
428
|
+
if (timeMin === undefined || timeMax === undefined)
|
|
429
|
+
return null;
|
|
430
|
+
const min = Date.parse(timeMin);
|
|
431
|
+
const max = Date.parse(timeMax);
|
|
432
|
+
if (Number.isNaN(min) || Number.isNaN(max))
|
|
433
|
+
return null;
|
|
434
|
+
if (min < max)
|
|
435
|
+
return null;
|
|
436
|
+
return min === max
|
|
437
|
+
? `timeMin and timeMax are the same instant (${timeMin}), so the window contains nothing.`
|
|
438
|
+
: `timeMin (${timeMin}) is after timeMax (${timeMax}).`;
|
|
439
|
+
}
|
|
440
|
+
export const TIME_RANGE_HINT = 'Swap the two values or widen the window: timeMin must be strictly earlier than timeMax. ' +
|
|
441
|
+
'An empty result from this tool then always means "nothing scheduled", never "bad range".';
|
|
382
442
|
export function formatEvent(event, opts = {}) {
|
|
383
443
|
const full = opts.full ?? true;
|
|
384
444
|
const description = event.description ?? '';
|
package/dist/tools/chat.js
CHANGED
|
@@ -3,7 +3,7 @@ import { coerceJson } from './_coerce.js';
|
|
|
3
3
|
import { chat as chatClient } from '@googleapis/chat';
|
|
4
4
|
import { accountAliasSchema } from '../accounts.js';
|
|
5
5
|
import { getClient } from '../client.js';
|
|
6
|
-
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
+
import { handleGoogleApiError, invalidParams } from './_errors.js';
|
|
7
7
|
const accountEnum = accountAliasSchema.optional();
|
|
8
8
|
export function registerChatTools(server) {
|
|
9
9
|
server.registerTool('chat_spaces_list', {
|
|
@@ -35,7 +35,7 @@ export function registerChatTools(server) {
|
|
|
35
35
|
description: 'Get details about a single Chat space',
|
|
36
36
|
inputSchema: {
|
|
37
37
|
account: accountEnum.describe('Google account alias'),
|
|
38
|
-
name: z.string().describe('Space resource name, format: spaces/{space}'),
|
|
38
|
+
name: z.string().min(1).describe('Space resource name, format: spaces/{space}'),
|
|
39
39
|
},
|
|
40
40
|
}, async ({ account, name }) => {
|
|
41
41
|
try {
|
|
@@ -63,7 +63,7 @@ export function registerChatTools(server) {
|
|
|
63
63
|
}, async ({ account, parent, text, cardsV2, threadKey, messageReplyOption }) => {
|
|
64
64
|
try {
|
|
65
65
|
if (!text && (!cardsV2 || cardsV2.length === 0)) {
|
|
66
|
-
return
|
|
66
|
+
return invalidParams(account, 'A message needs content: neither text nor cardsV2 was supplied.', 'Pass text for a plain message, or cardsV2 for a Card v2 payload. Both may be sent together.');
|
|
67
67
|
}
|
|
68
68
|
const auth = await getClient(account);
|
|
69
69
|
const chat = chatClient({ version: 'v1', auth });
|
|
@@ -118,5 +118,8 @@ export function registerChatTools(server) {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
function handleChatError(error, account) {
|
|
121
|
-
return handleGoogleApiError(error, account,
|
|
121
|
+
return handleGoogleApiError(error, account, {
|
|
122
|
+
scope: 'Chat tools require the optional "chat" scope bundle: add it to this account\'s scope profile, then re-auth.',
|
|
123
|
+
resource: `Google Chat denied this space or message to "${account}". The scope is not the problem: check that the account is a member of the space, and that Chat is turned on for the Workspace.`,
|
|
124
|
+
});
|
|
122
125
|
}
|