Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-calendar-timezone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
---

fix: Strip Z suffix from Google Calendar dateTime to fix timezone

Applied the same timezone fix to Google Calendar events that was applied to
Zoom meetings. The Z suffix was causing Google Calendar to interpret times
as UTC, resulting in meetings showing 5 hours earlier than intended for ET.

For example, "1 PM ET" was showing as "8 AM ET" because the Z suffix told
Google Calendar the time was 13:00 UTC (which is 8 AM ET).
12 changes: 12 additions & 0 deletions .changeset/fix-meeting-id-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
---

fix: Accept both UUID and Zoom meeting IDs in meeting tools

Updated meeting tools to accept both internal UUIDs and Zoom meeting IDs:
- Show meeting ID in list_upcoming_meetings output
- get_meeting_details, cancel_meeting, rsvp_to_meeting, add_meeting_attendee
now try UUID first, then fall back to Zoom meeting ID lookup

This fixes the issue where Claude would see Zoom meeting IDs in meeting links
but the tools only accepted internal UUIDs.
60 changes: 42 additions & 18 deletions server/src/addie/mcp/meeting-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ export function createMeetingToolHandlers(

for (const meeting of meetings) {
response += `📅 **${meeting.title}**\n`;
response += ` ID: ${meeting.id}\n`;
response += ` ${formatDate(meeting.start_time)} at ${formatTime(meeting.start_time, meeting.timezone)}\n`;
if (!groupName) {
response += ` Group: ${meeting.working_group_name}\n`;
Expand Down Expand Up @@ -707,14 +708,22 @@ export function createMeetingToolHandlers(

// Get meeting details
handlers.set('get_meeting_details', async (input) => {
const meetingId = input.meeting_id as string;
const inputId = input.meeting_id as string;

const meeting = await meetingsDb.getMeetingWithGroup(meetingId);
// Try to find by our UUID first, then by Zoom meeting ID
let meeting = await meetingsDb.getMeetingWithGroup(inputId);
if (!meeting) {
// Maybe it's a Zoom meeting ID - look that up first
const meetingByZoom = await meetingsDb.getMeetingByZoomId(inputId);
if (meetingByZoom) {
meeting = await meetingsDb.getMeetingWithGroup(meetingByZoom.id);
}
}
if (!meeting) {
return `❌ Meeting not found: "${meetingId}"`;
return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`;
}

const attendees = await meetingsDb.getAttendeesForMeeting(meetingId);
const attendees = await meetingsDb.getAttendeesForMeeting(meeting.id);
const accepted = attendees.filter(a => a.rsvp_status === 'accepted');
const declined = attendees.filter(a => a.rsvp_status === 'declined');
const pending = attendees.filter(a => a.rsvp_status === 'pending');
Expand Down Expand Up @@ -759,21 +768,25 @@ export function createMeetingToolHandlers(
return '❌ Unable to identify you. Please make sure you\'re logged in.';
}

const meetingId = input.meeting_id as string;
const inputId = input.meeting_id as string;
const response = input.response as 'accepted' | 'declined' | 'tentative';
const note = input.note as string | undefined;

const meeting = await meetingsDb.getMeetingById(meetingId);
// Try to find by our UUID first, then by Zoom meeting ID
let meeting = await meetingsDb.getMeetingById(inputId);
if (!meeting) {
meeting = await meetingsDb.getMeetingByZoomId(inputId);
}
if (!meeting) {
return `❌ Meeting not found: "${meetingId}"`;
return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`;
}

// Check if user is already an attendee
let attendee = await meetingsDb.getAttendee(meetingId, userId);
let attendee = await meetingsDb.getAttendee(meeting.id, userId);

if (attendee) {
// Update existing RSVP
attendee = await meetingsDb.updateAttendee(meetingId, userId, {
attendee = await meetingsDb.updateAttendee(meeting.id, userId, {
rsvp_status: response,
rsvp_note: note,
});
Expand All @@ -785,7 +798,7 @@ export function createMeetingToolHandlers(
: userEmail;

attendee = await meetingsDb.addAttendee({
meeting_id: meetingId,
meeting_id: meeting.id,
workos_user_id: userId,
email: userEmail,
name: userName,
Expand All @@ -810,17 +823,23 @@ export function createMeetingToolHandlers(

const meetingId = input.meeting_id as string;

const meeting = await meetingsDb.getMeetingById(meetingId);
// Try to find by our UUID first, then by Zoom meeting ID
let meeting = await meetingsDb.getMeetingById(meetingId);
if (!meeting) {
// Maybe it's a Zoom meeting ID instead of our UUID
meeting = await meetingsDb.getMeetingByZoomId(meetingId);
}
if (!meeting) {
return `❌ Meeting not found: "${meetingId}"`;
return `❌ Meeting not found: "${meetingId}". Use list_upcoming_meetings to find the correct meeting ID.`;
}

if (meeting.status === 'cancelled') {
return `Meeting "${meeting.title}" is already cancelled.`;
}

try {
const result = await meetingService.cancelMeeting(meetingId);
// Use meeting.id (our UUID) not the input meetingId (might be Zoom ID)
const result = await meetingService.cancelMeeting(meeting.id);

let response = `✅ Cancelled: **${meeting.title}**\n`;
response += `Cancellation notices have been sent to attendees.`;
Expand All @@ -830,7 +849,7 @@ export function createMeetingToolHandlers(
response += result.errors.map(e => `• ${e}`).join('\n');
}

logger.info({ meetingId, cancelledBy: getUserId() }, 'Meeting cancelled via Addie');
logger.info({ meetingId: meeting.id, cancelledBy: getUserId() }, 'Meeting cancelled via Addie');

return response;
} catch (error) {
Expand All @@ -844,17 +863,22 @@ export function createMeetingToolHandlers(
const permCheck = await checkSchedulePermission();
if (permCheck) return permCheck;

const meetingId = input.meeting_id as string;
const inputId = input.meeting_id as string;
const email = input.email as string;
const name = input.name as string | undefined;

const meeting = await meetingsDb.getMeetingById(meetingId);
// Try to find by our UUID first, then by Zoom meeting ID
let meeting = await meetingsDb.getMeetingById(inputId);
if (!meeting) {
meeting = await meetingsDb.getMeetingByZoomId(inputId);
}
if (!meeting) {
return `❌ Meeting not found: "${meetingId}"`;
return `❌ Meeting not found: "${inputId}". Use list_upcoming_meetings to find the correct meeting ID.`;
}

try {
const result = await meetingService.addAttendeesToMeeting(meetingId, [
// Use meeting.id (our UUID) not the inputId (might be Zoom ID)
const result = await meetingService.addAttendeesToMeeting(meeting.id, [
{ email, name },
]);

Expand Down
25 changes: 13 additions & 12 deletions server/src/services/meeting-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ const ZOOM_HOST_EMAIL = process.env.ZOOM_HOST_EMAIL || 'addie@agenticadvertising

/**
* Format a Date as ISO string WITHOUT the Z suffix.
* This is needed for Zoom API which interprets times with Z as UTC,
* but times without Z in the specified timezone parameter.
* This is needed for both Zoom and Google Calendar APIs which interpret
* times with Z as UTC, but times without Z in the specified timezone parameter.
*
* When Claude sends "2026-01-15T11:00:00" for 11 AM ET, JavaScript parses it
* as 11:00 UTC (server local time). By stripping the Z suffix, we tell Zoom
* to interpret "2026-01-15T11:00:00" in the timezone we specify (America/New_York),
* resulting in the correct 11:00 AM ET meeting time.
* When Claude sends "2026-01-15T13:00:00" for 1 PM, JavaScript parses it
* as 13:00 server local time (UTC on Fly.io). By stripping the Z suffix,
* we tell the APIs to interpret "2026-01-15T13:00:00" in the timezone we
* specify (America/New_York), resulting in the correct 1 PM ET meeting time.
*/
function formatDateForZoom(date: Date): string {
// toISOString() returns "2026-01-15T11:00:00.000Z"
// We strip the milliseconds and Z to get "2026-01-15T11:00:00"
function formatDateWithoutZ(date: Date): string {
// toISOString() returns "2026-01-15T13:00:00.000Z"
// We strip the milliseconds and Z to get "2026-01-15T13:00:00"
return date.toISOString().replace(/\.\d{3}Z$/, '');
}

Expand Down Expand Up @@ -103,7 +103,7 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise<
zoomMeeting = await zoom.createMeeting(ZOOM_HOST_EMAIL, {
topic: `${workingGroup.name}: ${options.title}`,
// Format without Z suffix so Zoom interprets in the specified timezone
start_time: formatDateForZoom(options.startTime),
start_time: formatDateWithoutZ(options.startTime),
duration: durationMinutes,
timezone,
agenda: options.agenda || options.description,
Expand Down Expand Up @@ -172,15 +172,16 @@ export async function scheduleMeeting(options: ScheduleMeetingOptions): Promise<
}));

// Build calendar event
// Use formatDateWithoutZ so Google interprets in the specified timezone
const eventInput: calendar.CreateCalendarEventInput = {
summary: `${workingGroup.name}: ${options.title}`,
description: buildCalendarDescription(options.description, options.agenda, zoomMeeting),
start: {
dateTime: options.startTime.toISOString(),
dateTime: formatDateWithoutZ(options.startTime),
timeZone: timezone,
},
end: {
dateTime: endTime.toISOString(),
dateTime: formatDateWithoutZ(endTime),
timeZone: timezone,
},
attendees: attendeeEmails,
Expand Down