Summary
calendar.updateEvent treats attendees as a full replacement of the guest list. So the most common phrasing a user gives an agent — "add Ana to that meeting" — silently removes everyone else who was already invited.
This is not the events.update vs events.patch bug (that one is fixed; updateEvent correctly uses patch today). patch merges at the field level, but attendees is a single field holding an array, and Google replaces the array wholesale. So a partial body still wipes the other guests, and the API returns 200 with the stripped guest list.
Reproduction
- Create an event with attendees
a@example.com and b@example.com.
calendar.updateEvent({ eventId, attendees: ["c@example.com"] }).
- The event now has exactly one attendee,
c@example.com. a@ and b@ were removed, and they receive cancellation notices.
Proposal
Make attendees mean add, which is what a caller asking to add a guest means, and add an explicit removeAttendees for the opposite intent — a merge alone can never remove anybody, so the removal case needs its own door.
One deliberate exception preserves existing behaviour: an empty attendees array still writes straight through as "clear the guest list". Merging there would make the one intent that cannot be expressed any other way impossible, and it keeps the current semantics (and its test) intact.
Patch
CalendarService.ts, in updateEvent, replacing:
if (attendees !== undefined)
requestBody.attendees = attendees.map((email) => ({ email }));
with:
// Attendees need read-modify-write even under patch: Google replaces the
// whole array rather than merging into it, so "add one guest" has to
// resend everyone.
if (attendees?.length === 0 && !removeAttendees?.length) {
requestBody.attendees = [];
} else if (attendees?.length || removeAttendees?.length) {
const current = await calendar.events.get({
calendarId: finalCalendarId,
eventId,
});
const dropped = new Set((removeAttendees ?? []).map((e) => e.toLowerCase()));
const merged = new Map<string, calendar_v3.Schema$EventAttendee>();
for (const a of current.data.attendees ?? []) {
const email = a.email?.toLowerCase();
if (!email || dropped.has(email)) continue;
merged.set(email, a);
}
for (const email of attendees ?? []) {
const key = email.toLowerCase();
if (dropped.has(key) || merged.has(key)) continue;
merged.set(key, { email });
}
requestBody.attendees = [...merged.values()];
}
Plus removeAttendees?: string[] on UpdateEventInput, and on the tool schema in index.ts:
attendees: z
.array(z.string())
.optional()
.describe(
'Email addresses to ADD as attendees. Merged with the guests already on the event, so existing guests are kept. Use removeAttendees to drop someone.',
),
removeAttendees: z
.array(z.string())
.optional()
.describe('Email addresses to remove from the event.'),
Notes on the details that matter:
- Existing attendee objects are re-sent whole, not rebuilt from the email, so each guest keeps their
responseStatus. Rebuilding them would reset everyone to needsAction and re-notify the whole list.
- Comparison is case-insensitive, since Google is.
- The read is skipped entirely when neither list is given, so the common "just change the title" path still costs one API call.
Verification
- Full suite green. The merge assertions were watched fail first by reverting the code to replacement semantics, before being trusted.
- Live against the Google API: adding a second guest to an event that already had one leaves both on the event, with the first guest's response status preserved.
Trade-off, stated plainly
This changes the meaning of attendees on update, so it is a behaviour change, not a pure bug fix. The argument for it is that the current meaning is the one nobody asks for: an agent told "add someone" has no way to comply without first reading the event itself, and every client that does not know to do that silently destroys data. If you would rather keep replacement and add addAttendees/removeAttendees as new fields, the same merge logic applies unchanged.
Happy to open a PR, though the CLA is a gate on our side — the patch above is complete either way.
Summary
calendar.updateEventtreatsattendeesas a full replacement of the guest list. So the most common phrasing a user gives an agent — "add Ana to that meeting" — silently removes everyone else who was already invited.This is not the
events.updatevsevents.patchbug (that one is fixed;updateEventcorrectly usespatchtoday).patchmerges at the field level, butattendeesis a single field holding an array, and Google replaces the array wholesale. So a partial body still wipes the other guests, and the API returns 200 with the stripped guest list.Reproduction
a@example.comandb@example.com.calendar.updateEvent({ eventId, attendees: ["c@example.com"] }).c@example.com.a@andb@were removed, and they receive cancellation notices.Proposal
Make
attendeesmean add, which is what a caller asking to add a guest means, and add an explicitremoveAttendeesfor the opposite intent — a merge alone can never remove anybody, so the removal case needs its own door.One deliberate exception preserves existing behaviour: an empty
attendeesarray still writes straight through as "clear the guest list". Merging there would make the one intent that cannot be expressed any other way impossible, and it keeps the current semantics (and its test) intact.Patch
CalendarService.ts, inupdateEvent, replacing:with:
Plus
removeAttendees?: string[]onUpdateEventInput, and on the tool schema inindex.ts:Notes on the details that matter:
responseStatus. Rebuilding them would reset everyone toneedsActionand re-notify the whole list.Verification
Trade-off, stated plainly
This changes the meaning of
attendeeson update, so it is a behaviour change, not a pure bug fix. The argument for it is that the current meaning is the one nobody asks for: an agent told "add someone" has no way to comply without first reading the event itself, and every client that does not know to do that silently destroys data. If you would rather keep replacement and addaddAttendees/removeAttendeesas new fields, the same merge logic applies unchanged.Happy to open a PR, though the CLA is a gate on our side — the patch above is complete either way.