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.
Files changed (103) hide show
  1. package/README.md +5 -3
  2. package/dist/accounts.d.ts +5 -0
  3. package/dist/accounts.js +12 -1
  4. package/dist/api-probe.js +3 -0
  5. package/dist/arg-normalize.d.ts +61 -4
  6. package/dist/arg-normalize.js +228 -4
  7. package/dist/arg-strict.d.ts +54 -0
  8. package/dist/arg-strict.js +163 -0
  9. package/dist/auth.js +15 -72
  10. package/dist/client.js +15 -5
  11. package/dist/config-file.d.ts +1 -0
  12. package/dist/config-file.js +4 -0
  13. package/dist/discover.js +35 -14
  14. package/dist/discovery-client.d.ts +8 -1
  15. package/dist/discovery-client.js +96 -17
  16. package/dist/doctor.d.ts +9 -1
  17. package/dist/doctor.js +32 -4
  18. package/dist/env-load.d.ts +11 -0
  19. package/dist/env-load.js +23 -1
  20. package/dist/executor.js +12 -1
  21. package/dist/fanout.js +43 -8
  22. package/dist/http-transport.d.ts +11 -2
  23. package/dist/http-transport.js +10 -5
  24. package/dist/index.js +112 -12
  25. package/dist/metrics-cli.d.ts +21 -0
  26. package/dist/metrics-cli.js +206 -0
  27. package/dist/metrics-tap.d.ts +12 -0
  28. package/dist/metrics-tap.js +95 -0
  29. package/dist/oauth-consent.d.ts +25 -10
  30. package/dist/oauth-consent.js +85 -39
  31. package/dist/outbound-allowlist.d.ts +34 -0
  32. package/dist/outbound-allowlist.js +108 -0
  33. package/dist/registry.d.ts +49 -2
  34. package/dist/registry.js +147 -7
  35. package/dist/scope-catalog.js +16 -0
  36. package/dist/services.d.ts +8 -0
  37. package/dist/services.js +28 -1
  38. package/dist/setup-prompt.d.ts +1 -1
  39. package/dist/setup-prompt.js +1 -1
  40. package/dist/tools/_coerce.d.ts +5 -0
  41. package/dist/tools/_coerce.js +11 -0
  42. package/dist/tools/_errors.d.ts +31 -3
  43. package/dist/tools/_errors.js +307 -43
  44. package/dist/tools/_local-files.js +7 -2
  45. package/dist/tools/account-wizard.d.ts +24 -1
  46. package/dist/tools/account-wizard.js +152 -51
  47. package/dist/tools/admin.js +7 -6
  48. package/dist/tools/analytics.d.ts +18 -0
  49. package/dist/tools/analytics.js +284 -0
  50. package/dist/tools/calendar.d.ts +7 -0
  51. package/dist/tools/calendar.js +75 -15
  52. package/dist/tools/chat.js +7 -4
  53. package/dist/tools/contacts.js +57 -30
  54. package/dist/tools/docs.js +56 -54
  55. package/dist/tools/drive.js +132 -70
  56. package/dist/tools/forms.js +7 -7
  57. package/dist/tools/generated/_shared.d.ts +6 -0
  58. package/dist/tools/generated/_shared.js +11 -1
  59. package/dist/tools/generated/admin.js +345 -214
  60. package/dist/tools/generated/analytics.d.ts +2 -0
  61. package/dist/tools/generated/analytics.js +981 -0
  62. package/dist/tools/generated/appsmarket.js +4 -4
  63. package/dist/tools/generated/calendar.js +28 -28
  64. package/dist/tools/generated/chat.js +83 -56
  65. package/dist/tools/generated/classroom.js +258 -216
  66. package/dist/tools/generated/cloudidentity.js +76 -68
  67. package/dist/tools/generated/cloudsearch.js +40 -39
  68. package/dist/tools/generated/contacts.js +20 -11
  69. package/dist/tools/generated/drive.js +62 -53
  70. package/dist/tools/generated/drivelabels.js +60 -32
  71. package/dist/tools/generated/forms.js +7 -7
  72. package/dist/tools/generated/gmail.js +136 -109
  73. package/dist/tools/generated/groupsmigration.js +1 -1
  74. package/dist/tools/generated/groupssettings.js +3 -3
  75. package/dist/tools/generated/index.js +2 -0
  76. package/dist/tools/generated/keep.js +8 -7
  77. package/dist/tools/generated/licensing.js +39 -23
  78. package/dist/tools/generated/meet.js +14 -14
  79. package/dist/tools/generated/method-map.d.ts +2 -0
  80. package/dist/tools/generated/method-map.js +894 -0
  81. package/dist/tools/generated/postmaster.js +3 -3
  82. package/dist/tools/generated/reseller.js +34 -28
  83. package/dist/tools/generated/script.js +29 -19
  84. package/dist/tools/generated/searchconsole.js +5 -3
  85. package/dist/tools/generated/sheets.js +10 -9
  86. package/dist/tools/generated/tasks.js +10 -4
  87. package/dist/tools/generated/vault.js +65 -56
  88. package/dist/tools/generated/workspaceevents.js +17 -16
  89. package/dist/tools/gmail.d.ts +10 -1
  90. package/dist/tools/gmail.js +50 -18
  91. package/dist/tools/google-api.js +48 -19
  92. package/dist/tools/meet.js +5 -2
  93. package/dist/tools/searchconsole.js +10 -10
  94. package/dist/tools/sheets.js +36 -48
  95. package/dist/tools/slides.js +6 -6
  96. package/dist/tools/tasks.js +16 -16
  97. package/dist/trim.d.ts +21 -0
  98. package/dist/trim.js +47 -0
  99. package/dist/usage-metrics.d.ts +155 -0
  100. package/dist/usage-metrics.js +603 -0
  101. package/dist/write-control.d.ts +27 -1
  102. package/dist/write-control.js +90 -11
  103. 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
+ }
@@ -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
  }): {
@@ -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 { handleGoogleApiError } from './_errors.js';
7
- import { sliceClean } from '../trim.js';
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 (!timeMin && !timeMax) {
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, null, 2) }],
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(events, null, 2) }],
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
- content: [{ type: 'text', text: JSON.stringify(res.data.calendars, null, 2) }],
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 ?? '';
@@ -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 { content: [{ type: 'text', text: JSON.stringify({ error: 'Either text or cardsV2 must be provided' }) }], isError: true };
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, "Chat tools require the optional \"chat\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=chat and re-auth.");
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
  }