mcp-google-multi 6.0.0-alpha.74 → 6.0.0-alpha.75

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 CHANGED
@@ -4,7 +4,7 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](https://www.npmjs.com/package/mcp-google-multi)
6
6
 
7
- - 🧰 **Exhaustive** — 940 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else → [COVERAGE.md](./COVERAGE.md)
7
+ - 🧰 **Exhaustive** — 941 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
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)
package/dist/registry.js CHANGED
@@ -59,6 +59,8 @@ const CUD_OVERRIDES = {
59
59
  drive_transfer: 'create',
60
60
  // read-only resolver; the name's "resolve" verb would otherwise infer update.
61
61
  contacts_resolve: 'read',
62
+ // "respond" matches no write verb; it patches the event (a write).
63
+ calendar_respond_event: 'update',
62
64
  // Gated by anthropic/requiresUserInteraction, not write-control, so
63
65
  // onboarding works under any profile; cud=read also keeps them off fan-out.
64
66
  account_add: 'read',
@@ -68,7 +68,12 @@ export function registerAdminTools(server, deps = {}) {
68
68
  account: accountEnum.describe('Google account alias (must be a Workspace admin)'),
69
69
  customer: z.string().default('my_customer').optional(),
70
70
  domain: z.string().optional(),
71
- query: z.string().optional().describe('Search query, e.g. "name:John"'),
71
+ query: z.string().optional().describe('Directory search query: space-separated clauses of field+operator+value, ANDed together. ' +
72
+ '"=" is exact match, ":" is a contains/startsWith match. Searchable fields include email, name ' +
73
+ '(matches the concatenated givenName+familyName), givenName, familyName, isAdmin, isDelegatedAdmin, ' +
74
+ 'orgName, orgTitle, orgDepartment, manager, directManager, addressCountry. Prefix/wildcard matching ' +
75
+ 'is a trailing "*" on a ":" clause, e.g. "email:admin*" for every address starting with "admin". ' +
76
+ 'Examples: \'name:John\', \'isAdmin=true\', \'orgTitle:Manager orgName=Engineering\'.'),
72
77
  maxResults: z.number().min(1).max(500).optional(),
73
78
  pageToken: z.string().optional(),
74
79
  orderBy: z.enum(['email', 'familyName', 'givenName']).optional(),
@@ -4,7 +4,7 @@ import { analyticsdata as analyticsdataClient } from '@googleapis/analyticsdata'
4
4
  import { analyticsadmin as analyticsadminClient } from '@googleapis/analyticsadmin';
5
5
  import { accountArgLive } from '../accounts.js';
6
6
  import { getClient } from '../client.js';
7
- import { handleGoogleApiError } from './_errors.js';
7
+ import { handleGoogleApiError, invalidParams } from './_errors.js';
8
8
  /** Accepts "213025502" or "properties/213025502"; rejects the identifiers
9
9
  * people paste by mistake (G-… measurement IDs, UA-… properties) with a
10
10
  * pointer to the right one. */
@@ -104,6 +104,11 @@ export function shapeMetadata(data) {
104
104
  const propertySchema = z
105
105
  .string()
106
106
  .describe('GA4 property: numeric ID like "213025502" or "properties/213025502" — NOT a "G-..." measurement ID and not "UA-...". Find yours with analytics_account_summaries.');
107
+ // Accepts this server's plain string form and the native GA4 API shape ({name}),
108
+ // so a request built against GA4's own docs works unmodified.
109
+ const metricOrDimensionRef = z.union([z.string(), z.object({ name: z.string() })]);
110
+ const nameOf = (ref) => (typeof ref === 'string' ? ref : ref.name);
111
+ const dateRangeRef = z.object({ startDate: z.string(), endDate: z.string() });
107
112
  export function registerAnalyticsTools(server, deps = {}) {
108
113
  // Per-registry, live account enum + injectable client: the schema follows
109
114
  // the registry's account view at parse time, not a process-global default.
@@ -130,16 +135,19 @@ export function registerAnalyticsTools(server, deps = {}) {
130
135
  }
131
136
  });
132
137
  server.registerTool('analytics_run_report', {
133
- 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.',
138
+ 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"). Also accepts the native GA4 request shapes directly: a single dateRanges entry in place of startDate/endDate, and metrics/dimensions as {name} objects. Unsure which dimension/metric names are valid? Call analytics_get_metadata first.',
134
139
  inputSchema: {
135
140
  account: accountEnum.describe('Google account alias'),
136
141
  property: propertySchema,
137
- startDate: z.string().describe('Start date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo" e.g. "28daysAgo")'),
138
- endDate: z.string().describe('End date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo")'),
139
- 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.'),
140
- dimensions: coerceArray(z.string())
142
+ startDate: z.string().optional().describe('Start date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo" e.g. "28daysAgo"). Required unless dateRanges is given.'),
143
+ endDate: z.string().optional().describe('End date: YYYY-MM-DD or relative ("today", "yesterday", "NdaysAgo"). Required unless dateRanges is given.'),
144
+ dateRanges: coerceJson(z.array(dateRangeRef))
141
145
  .optional()
142
- .describe('Dimension API names to group by, e.g. ["date"] or ["country","deviceCategory"]. Max 9. Omit for a single total row.'),
146
+ .describe('Native GA4 shape, as an alternative to startDate/endDate: exactly one {startDate, endDate} entry (this tool runs one range per call).'),
147
+ metrics: coerceArray(metricOrDimensionRef).describe('Metric API names, e.g. ["activeUsers","sessions","screenPageViews"], or the native GA4 [{"name":"activeUsers"}] form. Max 10. Valid names (including custom ones) come from analytics_get_metadata.'),
148
+ dimensions: coerceArray(metricOrDimensionRef)
149
+ .optional()
150
+ .describe('Dimension API names to group by, e.g. ["date"] or ["country","deviceCategory"], or the native GA4 {"name":...} form. Max 9. Omit for a single total row.'),
143
151
  dimensionFilter: coerceJson(z.record(z.string(), z.unknown()))
144
152
  .optional()
145
153
  .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.'),
@@ -157,19 +165,28 @@ export function registerAnalyticsTools(server, deps = {}) {
157
165
  keepEmptyRows: coerceBoolean.optional().describe('Include rows whose metrics are all zero (default false)'),
158
166
  returnPropertyQuota: coerceBoolean.optional().describe("Include this property's remaining quota tokens in the response"),
159
167
  },
160
- }, async ({ account, property, startDate, endDate, metrics, dimensions, dimensionFilter, metricFilter, orderBys, limit, offset, metricAggregations, keepEmptyRows, returnPropertyQuota }) => {
168
+ }, async ({ account, property, startDate, endDate, dateRanges, metrics, dimensions, dimensionFilter, metricFilter, orderBys, limit, offset, metricAggregations, keepEmptyRows, returnPropertyQuota }) => {
161
169
  const prop = normalizeProperty(property);
162
170
  if ('hint' in prop)
163
171
  return invalidProperty(prop.hint, account);
172
+ if (dateRanges !== undefined && dateRanges.length > 1) {
173
+ return invalidParams(account, `dateRanges has ${dateRanges.length} entries, but analytics_run_report runs a single range per call.`, 'Pass exactly one {startDate, endDate} entry, or call the tool once per range.');
174
+ }
175
+ const range = dateRanges?.[0];
176
+ const resolvedStart = range?.startDate ?? startDate;
177
+ const resolvedEnd = range?.endDate ?? endDate;
178
+ if (resolvedStart === undefined || resolvedEnd === undefined) {
179
+ return invalidParams(account, 'Missing a date range: pass startDate and endDate, or a single-entry dateRanges.', 'e.g. startDate: "28daysAgo", endDate: "today", or dateRanges: [{"startDate":"28daysAgo","endDate":"today"}].');
180
+ }
164
181
  try {
165
182
  const auth = await getClientFn(account);
166
183
  const dataApi = analyticsdataClient({ version: 'v1beta', auth });
167
184
  const requestBody = {
168
- dateRanges: [{ startDate, endDate }],
169
- metrics: metrics.map((name) => ({ name })),
185
+ dateRanges: [{ startDate: resolvedStart, endDate: resolvedEnd }],
186
+ metrics: metrics.map((ref) => ({ name: nameOf(ref) })),
170
187
  };
171
188
  if (dimensions?.length)
172
- requestBody.dimensions = dimensions.map((name) => ({ name }));
189
+ requestBody.dimensions = dimensions.map((ref) => ({ name: nameOf(ref) }));
173
190
  if (dimensionFilter)
174
191
  requestBody.dimensionFilter = dimensionFilter;
175
192
  if (metricFilter)
@@ -8,6 +8,11 @@ export declare function registerCalendarTools(server: ToolRegistry, deps?: Curat
8
8
  * Date.parse is lenient, so it is used only to SKIP the check, never to accept. */
9
9
  export declare function timeRangeError(timeMin: string | undefined, timeMax: string | undefined): string | null;
10
10
  export declare const TIME_RANGE_HINT: string;
11
+ /** The Calendar API requires timeMin/timeMax as RFC3339 with a mandatory
12
+ * offset; a bare local datetime is silently ambiguous rather than rejected,
13
+ * so catch it here with a message that names the field. */
14
+ export declare function missingTimeZoneError(field: 'timeMin' | 'timeMax', value: string | undefined): string | null;
15
+ export declare const MISSING_TIME_ZONE_HINT = "Add \"Z\" for UTC or an explicit offset, e.g. \"2026-04-04T00:00:00Z\" or \"2026-04-04T00:00:00+01:00\".";
11
16
  export declare function formatEvent(event: any, opts?: {
12
17
  full?: boolean;
13
18
  }): {
@@ -54,6 +54,9 @@ export function registerCalendarTools(server, deps = {}) {
54
54
  .describe('Continuation token from a previous call\'s nextPageToken'),
55
55
  },
56
56
  }, async ({ account, calendarId, query, timeMin, timeMax, maxResults, pageToken }) => {
57
+ const tzError = missingTimeZoneError('timeMin', timeMin) ?? missingTimeZoneError('timeMax', timeMax);
58
+ if (tzError)
59
+ return invalidParams(account, tzError, MISSING_TIME_ZONE_HINT);
57
60
  const rangeError = timeRangeError(timeMin, timeMax);
58
61
  if (rangeError)
59
62
  return invalidParams(account, rangeError, TIME_RANGE_HINT);
@@ -236,6 +239,46 @@ export function registerCalendarTools(server, deps = {}) {
236
239
  return handleCalendarError(error, account);
237
240
  }
238
241
  });
242
+ // attendeesOmitted plus a single-entry attendees array patches only the caller's own RSVP.
243
+ server.registerTool('calendar_respond_event', {
244
+ description: 'RSVP to a calendar event as this account (accepted/declined/tentative) without touching any other attendee',
245
+ inputSchema: {
246
+ account: accountEnum.describe('Google account alias'),
247
+ eventId: z.string().min(1).describe('Calendar event ID'),
248
+ calendarId: z.string().default('primary').optional()
249
+ .describe('Calendar ID (default: primary)'),
250
+ response: z.enum(['accepted', 'declined', 'tentative']).describe('RSVP response'),
251
+ comment: z.string().optional().describe('Optional comment attached to the response'),
252
+ },
253
+ }, async ({ account, eventId, calendarId, response, comment }) => {
254
+ try {
255
+ const auth = await getClientFn(account);
256
+ const cal = calendarClient({ version: 'v3', auth });
257
+ const cid = calendarId ?? 'primary';
258
+ const current = await cal.events.get({ calendarId: cid, eventId });
259
+ const attendees = current.data.attendees ?? [];
260
+ const accountEmail = server.accountSet().configs[account]?.email;
261
+ const self = attendees.find((a) => a.self)
262
+ ?? attendees.find((a) => a.email?.toLowerCase() === accountEmail?.toLowerCase());
263
+ if (!self?.email) {
264
+ return invalidParams(account, `Event "${eventId}" has no attendee entry for this account, so there is nothing to RSVP to.`, 'RSVP only applies to events where this account is invited as a guest; check calendar_get_event to see the attendee list.');
265
+ }
266
+ const res = await cal.events.patch({
267
+ calendarId: cid,
268
+ eventId,
269
+ requestBody: {
270
+ attendeesOmitted: true,
271
+ attendees: [{ email: self.email, responseStatus: response, ...(comment !== undefined ? { comment } : {}) }],
272
+ },
273
+ });
274
+ return {
275
+ content: [{ type: 'text', text: JSON.stringify(formatEvent(res.data), null, 2) }],
276
+ };
277
+ }
278
+ catch (error) {
279
+ return handleCalendarError(error, account);
280
+ }
281
+ });
239
282
  server.registerTool('calendar_delete_event', {
240
283
  description: 'Delete a Google Calendar event',
241
284
  inputSchema: {
@@ -328,6 +371,9 @@ export function registerCalendarTools(server, deps = {}) {
328
371
  .describe('Continuation token from a previous call\'s nextPageToken'),
329
372
  },
330
373
  }, async ({ account, calendarId, eventId, timeMin, timeMax, maxResults, pageToken }) => {
374
+ const tzError = missingTimeZoneError('timeMin', timeMin) ?? missingTimeZoneError('timeMax', timeMax);
375
+ if (tzError)
376
+ return invalidParams(account, tzError, MISSING_TIME_ZONE_HINT);
331
377
  const rangeError = timeRangeError(timeMin, timeMax);
332
378
  if (rangeError)
333
379
  return invalidParams(account, rangeError, TIME_RANGE_HINT);
@@ -367,6 +413,9 @@ export function registerCalendarTools(server, deps = {}) {
367
413
  if (calendarIds.length === 0) {
368
414
  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.');
369
415
  }
416
+ const tzError = missingTimeZoneError('timeMin', timeMin) ?? missingTimeZoneError('timeMax', timeMax);
417
+ if (tzError)
418
+ return invalidParams(account, tzError, MISSING_TIME_ZONE_HINT);
370
419
  const rangeError = timeRangeError(timeMin, timeMax);
371
420
  if (rangeError)
372
421
  return invalidParams(account, rangeError, TIME_RANGE_HINT);
@@ -440,6 +489,16 @@ export function timeRangeError(timeMin, timeMax) {
440
489
  }
441
490
  export const TIME_RANGE_HINT = 'Swap the two values or widen the window: timeMin must be strictly earlier than timeMax. ' +
442
491
  'An empty result from this tool then always means "nothing scheduled", never "bad range".';
492
+ const TZ_SUFFIX_RE = /(Z|[+-]\d{2}:?\d{2})$/;
493
+ /** The Calendar API requires timeMin/timeMax as RFC3339 with a mandatory
494
+ * offset; a bare local datetime is silently ambiguous rather than rejected,
495
+ * so catch it here with a message that names the field. */
496
+ export function missingTimeZoneError(field, value) {
497
+ if (value === undefined || TZ_SUFFIX_RE.test(value))
498
+ return null;
499
+ return `${field} ("${value}") has no "Z" or UTC offset, so its timezone is ambiguous.`;
500
+ }
501
+ export const MISSING_TIME_ZONE_HINT = 'Add "Z" for UTC or an explicit offset, e.g. "2026-04-04T00:00:00Z" or "2026-04-04T00:00:00+01:00".';
443
502
  export function formatEvent(event, opts = {}) {
444
503
  const full = opts.full ?? true;
445
504
  const description = event.description ?? '';
@@ -234,8 +234,8 @@ export function registerContactsTools(server, deps = {}) {
234
234
  description: 'List all contacts (paginated)',
235
235
  inputSchema: {
236
236
  account: accountEnum.describe('Google account alias'),
237
- pageSize: z.number().min(1).max(1000).default(100).optional()
238
- .describe('Number of contacts per page (default: 100)'),
237
+ pageSize: z.number().min(1).max(1000).default(50).optional()
238
+ .describe('Number of contacts per page (default: 50)'),
239
239
  pageToken: z.string().optional().describe('Page token for pagination'),
240
240
  sortOrder: z.enum([
241
241
  'LAST_MODIFIED_ASCENDING',
@@ -244,19 +244,25 @@ export function registerContactsTools(server, deps = {}) {
244
244
  'LAST_NAME_ASCENDING',
245
245
  ]).default('FIRST_NAME_ASCENDING').optional()
246
246
  .describe('Sort order (default: FIRST_NAME_ASCENDING)'),
247
+ requireEmail: coerceBoolean.optional().describe('Only return contacts with at least one email address. Filtering happens after the page is fetched, so the returned page can hold fewer than pageSize contacts; nextPageToken still continues the underlying, unfiltered listing'),
248
+ requireName: coerceBoolean.optional().describe('Only return contacts with a name. Filtering happens after the page is fetched, so the returned page can hold fewer than pageSize contacts; nextPageToken still continues the underlying, unfiltered listing'),
247
249
  },
248
- }, async ({ account, pageSize, pageToken, sortOrder }) => {
250
+ }, async ({ account, pageSize, pageToken, sortOrder, requireEmail, requireName }) => {
249
251
  try {
250
252
  const auth = await getClientFn(account);
251
253
  const people = peopleClient({ version: 'v1', auth });
252
254
  const res = await people.people.connections.list({
253
255
  resourceName: 'people/me',
254
256
  personFields: PERSON_FIELDS,
255
- pageSize: pageSize ?? 100,
257
+ pageSize: pageSize ?? 50,
256
258
  pageToken: pageToken ?? undefined,
257
259
  sortOrder: sortOrder ?? 'FIRST_NAME_ASCENDING',
258
260
  });
259
- const contacts = (res.data.connections ?? []).map(formatContact);
261
+ let contacts = (res.data.connections ?? []).map(formatContact);
262
+ if (requireEmail)
263
+ contacts = contacts.filter((c) => c.emails.length > 0);
264
+ if (requireName)
265
+ contacts = contacts.filter((c) => c.name !== '');
260
266
  return {
261
267
  content: [{ type: 'text', text: JSON.stringify({
262
268
  contacts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "6.0.0-alpha.74",
3
+ "version": "6.0.0-alpha.75",
4
4
  "mcpName": "io.github.bakissation/mcp-google-multi",
5
5
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
6
6
  "type": "module",