google-calendar-workspace-mcp-server 0.0.11 → 0.0.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.
@@ -194,6 +194,9 @@ class MockCalendarClient {
194
194
  ],
195
195
  };
196
196
  }
197
+ async getAccountEmail() {
198
+ return 'me@example.com';
199
+ }
197
200
  async queryFreebusy(request) {
198
201
  return {
199
202
  kind: 'calendar#freeBusy',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-calendar-workspace-mcp-server",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "MCP server for Google Calendar integration with service account support",
5
5
  "mcpName": "com.pulsemcp/google-calendar",
6
6
  "main": "build/index.js",
@@ -32,11 +32,11 @@
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "^1.29.0",
34
34
  "google-auth-library": "^10.5.0",
35
- "zod": "^3.24.1"
35
+ "zod": "^3.25.76"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^22.10.6",
39
- "tsx": "^4.19.4",
39
+ "tsx": "^4.22.4",
40
40
  "typescript": "^5.7.3"
41
41
  },
42
42
  "keywords": [
@@ -52,6 +52,15 @@ export interface ICalendarClient {
52
52
  * Query free/busy information
53
53
  */
54
54
  queryFreebusy(request: FreeBusyRequest): Promise<FreeBusyResponse>;
55
+ /**
56
+ * Get the email address of the Google account this client reads from.
57
+ * Used to append `&authuser=<email>` to event URLs so they open against
58
+ * the correct account in multi-account browsers (the reader's default
59
+ * Google account may not be the calendar owner). Returns an empty string
60
+ * if the account email cannot be resolved — callers should treat empty
61
+ * as "skip authuser" and fall back to the bare `?eid=` form.
62
+ */
63
+ getAccountEmail(): Promise<string>;
55
64
  }
56
65
  /**
57
66
  * Service account credentials structure
@@ -72,6 +81,7 @@ export interface ServiceAccountCredentials {
72
81
  * Google Calendar API client implementation using service account with domain-wide delegation
73
82
  */
74
83
  export declare class ServiceAccountCalendarClient implements ICalendarClient {
84
+ private impersonateEmail;
75
85
  private baseUrl;
76
86
  private jwtClient;
77
87
  private cachedToken;
@@ -105,6 +115,7 @@ export declare class ServiceAccountCalendarClient implements ICalendarClient {
105
115
  pageToken?: string;
106
116
  }): Promise<CalendarList>;
107
117
  queryFreebusy(request: FreeBusyRequest): Promise<FreeBusyResponse>;
118
+ getAccountEmail(): Promise<string>;
108
119
  }
109
120
  export type ClientFactory = () => ICalendarClient;
110
121
  /**
package/shared/server.js CHANGED
@@ -5,12 +5,14 @@ import { createRegisterTools, parseEnabledToolGroups } from './tools.js';
5
5
  * Google Calendar API client implementation using service account with domain-wide delegation
6
6
  */
7
7
  export class ServiceAccountCalendarClient {
8
+ impersonateEmail;
8
9
  baseUrl = 'https://www.googleapis.com/calendar/v3';
9
10
  jwtClient;
10
11
  cachedToken = null;
11
12
  tokenExpiry = 0;
12
13
  refreshPromise = null;
13
14
  constructor(credentials, impersonateEmail) {
15
+ this.impersonateEmail = impersonateEmail;
14
16
  this.jwtClient = new JWT({
15
17
  email: credentials.client_email,
16
18
  key: credentials.private_key,
@@ -82,6 +84,9 @@ export class ServiceAccountCalendarClient {
82
84
  const { queryFreebusy } = await import('./calendar-client/lib/query-freebusy.js');
83
85
  return queryFreebusy(this.baseUrl, headers, request);
84
86
  }
87
+ async getAccountEmail() {
88
+ return this.impersonateEmail;
89
+ }
85
90
  }
86
91
  /**
87
92
  * Creates the default Google Calendar client based on environment variables.
@@ -167,7 +167,10 @@ export function createEventTool(server, clientFactory) {
167
167
  }
168
168
  // Determine if we need supportsAttachments parameter
169
169
  const hasAttachments = parsed.attachments && parsed.attachments.length > 0;
170
- const result = await client.createEvent(parsed.calendar_id, event, hasAttachments ? { supportsAttachments: true } : undefined);
170
+ const [result, accountEmail] = await Promise.all([
171
+ client.createEvent(parsed.calendar_id, event, hasAttachments ? { supportsAttachments: true } : undefined),
172
+ client.getAccountEmail(),
173
+ ]);
171
174
  let output = `# Event Created Successfully\n\n`;
172
175
  output += `## ${result.summary || '(No title)'}\n\n`;
173
176
  output += `**Event ID:** ${result.id}\n`;
@@ -215,8 +218,8 @@ export function createEventTool(server, clientFactory) {
215
218
  output += ` - ${attachment.title || attachment.fileUrl}\n`;
216
219
  }
217
220
  }
218
- // Link — universal calendar.google.com form (eid embeds calendar context).
219
- const eventUrl = buildCalendarEventUrl(result.htmlLink);
221
+ // Link — `?eid=&authuser=<email>` form so multi-account readers don't 404.
222
+ const eventUrl = buildCalendarEventUrl(result.htmlLink, accountEmail);
220
223
  if (eventUrl) {
221
224
  output += `\n**Event Link:** ${eventUrl}\n`;
222
225
  }
@@ -33,7 +33,10 @@ export function getEventTool(server, clientFactory) {
33
33
  try {
34
34
  const parsed = GetEventSchema.parse(args);
35
35
  const client = clientFactory();
36
- const event = await client.getEvent(parsed.calendar_id, parsed.event_id);
36
+ const [event, accountEmail] = await Promise.all([
37
+ client.getEvent(parsed.calendar_id, parsed.event_id),
38
+ client.getAccountEmail(),
39
+ ]);
37
40
  let output = `# Event Details\n\n`;
38
41
  output += `## ${event.summary || '(No title)'}\n\n`;
39
42
  output += `**Event ID:** ${event.id}\n`;
@@ -129,8 +132,8 @@ export function getEventTool(server, clientFactory) {
129
132
  if (event.updated) {
130
133
  output += `**Updated:** ${new Date(event.updated).toLocaleString()}\n`;
131
134
  }
132
- // Link — universal calendar.google.com form (eid embeds calendar context).
133
- const eventUrl = buildCalendarEventUrl(event.htmlLink);
135
+ // Link — `?eid=&authuser=<email>` form so multi-account readers don't 404.
136
+ const eventUrl = buildCalendarEventUrl(event.htmlLink, accountEmail);
134
137
  if (eventUrl) {
135
138
  output += `\n**Event Link:** ${eventUrl}\n`;
136
139
  }
@@ -78,14 +78,17 @@ export function listEventsTool(server, clientFactory) {
78
78
  try {
79
79
  const parsed = ListEventsSchema.parse(args);
80
80
  const client = clientFactory();
81
- const result = await client.listEvents(parsed.calendar_id, {
82
- timeMin: parsed.time_min,
83
- timeMax: parsed.time_max,
84
- maxResults: parsed.max_results,
85
- q: parsed.query,
86
- singleEvents: parsed.single_events,
87
- orderBy: parsed.order_by,
88
- });
81
+ const [result, accountEmail] = await Promise.all([
82
+ client.listEvents(parsed.calendar_id, {
83
+ timeMin: parsed.time_min,
84
+ timeMax: parsed.time_max,
85
+ maxResults: parsed.max_results,
86
+ q: parsed.query,
87
+ singleEvents: parsed.single_events,
88
+ orderBy: parsed.order_by,
89
+ }),
90
+ client.getAccountEmail(),
91
+ ]);
89
92
  const events = result.items || [];
90
93
  if (events.length === 0) {
91
94
  return {
@@ -157,8 +160,8 @@ export function listEventsTool(server, clientFactory) {
157
160
  : event.description;
158
161
  output += `**Description:** ${truncated}\n`;
159
162
  }
160
- // Link — universal calendar.google.com form (eid embeds calendar context).
161
- const eventUrl = buildCalendarEventUrl(event.htmlLink);
163
+ // Link — `?eid=&authuser=<email>` form so multi-account readers don't 404.
164
+ const eventUrl = buildCalendarEventUrl(event.htmlLink, accountEmail);
162
165
  if (eventUrl) {
163
166
  output += `**Link:** ${eventUrl}\n`;
164
167
  }
@@ -191,7 +191,10 @@ export function updateEventTool(server, clientFactory) {
191
191
  if (hasAttachments) {
192
192
  options.supportsAttachments = true;
193
193
  }
194
- const result = await client.updateEvent(parsed.calendar_id, parsed.event_id, eventUpdate, Object.keys(options).length > 0 ? options : undefined);
194
+ const [result, accountEmail] = await Promise.all([
195
+ client.updateEvent(parsed.calendar_id, parsed.event_id, eventUpdate, Object.keys(options).length > 0 ? options : undefined),
196
+ client.getAccountEmail(),
197
+ ]);
195
198
  let output = `# Event Updated Successfully\n\n`;
196
199
  output += `## ${result.summary || '(No title)'}\n\n`;
197
200
  output += `**Event ID:** ${result.id}\n`;
@@ -239,8 +242,8 @@ export function updateEventTool(server, clientFactory) {
239
242
  output += ` - ${attachment.title || attachment.fileUrl}\n`;
240
243
  }
241
244
  }
242
- // Link — universal calendar.google.com form (eid embeds calendar context).
243
- const eventUrl = buildCalendarEventUrl(result.htmlLink);
245
+ // Link — `?eid=&authuser=<email>` form so multi-account readers don't 404.
246
+ const eventUrl = buildCalendarEventUrl(result.htmlLink, accountEmail);
244
247
  if (eventUrl) {
245
248
  output += `\n**Event Link:** ${eventUrl}\n`;
246
249
  }
@@ -1,18 +1,23 @@
1
1
  /**
2
- * Builds a universal Google Calendar event URL on `calendar.google.com`.
2
+ * Builds a multi-account-robust Google Calendar event URL on `calendar.google.com`.
3
3
  *
4
- * The eid embedded in `htmlLink` already encodes the calendar context — it
5
- * base64-decodes to `<event-id> <calendar-id>`, so `calendar.google.com`
6
- * can route the click to the correct calendar regardless of which Google
7
- * accounts the reader is signed into.
4
+ * Emits `https://calendar.google.com/calendar/event?eid=<eid>&authuser=<accountEmail>`
5
+ * when `accountEmail` is non-empty. The `authuser` query parameter forces Google
6
+ * Calendar to evaluate the link against the named account regardless of which
7
+ * Google account is the reader's browser default — without it, multi-account
8
+ * readers whose default account isn't the calendar owner get a 404.
8
9
  *
9
- * The raw `htmlLink` from Google's API uses `www.google.com` and redirects
10
- * through Google's general router, which can guess wrong when the reader
11
- * is signed into multiple accounts. Pointing directly at `calendar.google.com`
12
- * with the same eid bypasses that redirect.
10
+ * Falls back to `https://calendar.google.com/calendar/event?eid=<eid>` (bare form)
11
+ * when `accountEmail` is empty/undefined. The bare form works for single-account
12
+ * readers, and the eid itself base64-decodes to `<event-id> <calendar-id>` so the
13
+ * calendar context still travels with the URL.
13
14
  *
14
- * Falls back to the original `htmlLink` if it cannot be parsed (e.g. the
15
- * API response omitted `htmlLink` or it lacks an `eid` parameter), and
16
- * returns `undefined` if `htmlLink` is missing entirely.
15
+ * NEVER emits the `/calendar/u/<email>/r/eventedit/<eid>` path form that
16
+ * reader-side path-based account selector 404s for any reader not currently
17
+ * signed in as `<email>` (shipped and reverted as v0.0.9; see PR #3670 follow-up).
18
+ *
19
+ * Falls back to the original `htmlLink` if it cannot be parsed (e.g. the API
20
+ * response omitted `htmlLink` or it lacks an `eid` parameter), and returns
21
+ * `undefined` if `htmlLink` is missing entirely.
17
22
  */
18
- export declare function buildCalendarEventUrl(htmlLink: string | undefined): string | undefined;
23
+ export declare function buildCalendarEventUrl(htmlLink: string | undefined, accountEmail?: string): string | undefined;
@@ -1,21 +1,26 @@
1
1
  /**
2
- * Builds a universal Google Calendar event URL on `calendar.google.com`.
2
+ * Builds a multi-account-robust Google Calendar event URL on `calendar.google.com`.
3
3
  *
4
- * The eid embedded in `htmlLink` already encodes the calendar context — it
5
- * base64-decodes to `<event-id> <calendar-id>`, so `calendar.google.com`
6
- * can route the click to the correct calendar regardless of which Google
7
- * accounts the reader is signed into.
4
+ * Emits `https://calendar.google.com/calendar/event?eid=<eid>&authuser=<accountEmail>`
5
+ * when `accountEmail` is non-empty. The `authuser` query parameter forces Google
6
+ * Calendar to evaluate the link against the named account regardless of which
7
+ * Google account is the reader's browser default — without it, multi-account
8
+ * readers whose default account isn't the calendar owner get a 404.
8
9
  *
9
- * The raw `htmlLink` from Google's API uses `www.google.com` and redirects
10
- * through Google's general router, which can guess wrong when the reader
11
- * is signed into multiple accounts. Pointing directly at `calendar.google.com`
12
- * with the same eid bypasses that redirect.
10
+ * Falls back to `https://calendar.google.com/calendar/event?eid=<eid>` (bare form)
11
+ * when `accountEmail` is empty/undefined. The bare form works for single-account
12
+ * readers, and the eid itself base64-decodes to `<event-id> <calendar-id>` so the
13
+ * calendar context still travels with the URL.
13
14
  *
14
- * Falls back to the original `htmlLink` if it cannot be parsed (e.g. the
15
- * API response omitted `htmlLink` or it lacks an `eid` parameter), and
16
- * returns `undefined` if `htmlLink` is missing entirely.
15
+ * NEVER emits the `/calendar/u/<email>/r/eventedit/<eid>` path form that
16
+ * reader-side path-based account selector 404s for any reader not currently
17
+ * signed in as `<email>` (shipped and reverted as v0.0.9; see PR #3670 follow-up).
18
+ *
19
+ * Falls back to the original `htmlLink` if it cannot be parsed (e.g. the API
20
+ * response omitted `htmlLink` or it lacks an `eid` parameter), and returns
21
+ * `undefined` if `htmlLink` is missing entirely.
17
22
  */
18
- export function buildCalendarEventUrl(htmlLink) {
23
+ export function buildCalendarEventUrl(htmlLink, accountEmail) {
19
24
  if (!htmlLink) {
20
25
  return undefined;
21
26
  }
@@ -23,7 +28,11 @@ export function buildCalendarEventUrl(htmlLink) {
23
28
  if (!eid) {
24
29
  return htmlLink;
25
30
  }
26
- return `https://calendar.google.com/calendar/event?eid=${eid}`;
31
+ const base = `https://calendar.google.com/calendar/event?eid=${eid}`;
32
+ if (accountEmail && accountEmail.length > 0) {
33
+ return `${base}&authuser=${encodeURIComponent(accountEmail)}`;
34
+ }
35
+ return base;
27
36
  }
28
37
  function extractEid(htmlLink) {
29
38
  try {