This is page 5 of 6. Use http://codebase.md/nspady/google-calendar-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursorignore
├── .dockerignore
├── .env.example
├── .github
│ └── workflows
│ ├── ci.yml
│ ├── publish.yml
│ └── README.md
├── .gitignore
├── .release-please-manifest.json
├── AGENTS.md
├── CHANGELOG.md
├── CLAUDE.md
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── advanced-usage.md
│ ├── architecture.md
│ ├── authentication.md
│ ├── deployment.md
│ ├── development.md
│ ├── docker.md
│ ├── README.md
│ └── testing.md
├── examples
│ ├── http-client.js
│ └── http-with-curl.sh
├── future_features
│ └── ARCHITECTURE_REDESIGN.md
├── gcp-oauth.keys.example.json
├── instructions
│ └── file_structure.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── account-manager.js
│ ├── build.js
│ ├── dev.js
│ └── test-docker.sh
├── src
│ ├── auth
│ │ ├── client.ts
│ │ ├── paths.d.ts
│ │ ├── paths.js
│ │ ├── server.ts
│ │ ├── tokenManager.ts
│ │ └── utils.ts
│ ├── auth-server.ts
│ ├── config
│ │ └── TransportConfig.ts
│ ├── handlers
│ │ ├── core
│ │ │ ├── BaseToolHandler.ts
│ │ │ ├── BatchRequestHandler.ts
│ │ │ ├── CreateEventHandler.ts
│ │ │ ├── DeleteEventHandler.ts
│ │ │ ├── FreeBusyEventHandler.ts
│ │ │ ├── GetCurrentTimeHandler.ts
│ │ │ ├── GetEventHandler.ts
│ │ │ ├── ListCalendarsHandler.ts
│ │ │ ├── ListColorsHandler.ts
│ │ │ ├── ListEventsHandler.ts
│ │ │ ├── RecurringEventHelpers.ts
│ │ │ ├── SearchEventsHandler.ts
│ │ │ └── UpdateEventHandler.ts
│ │ ├── utils
│ │ │ └── datetime.ts
│ │ └── utils.ts
│ ├── index.ts
│ ├── schemas
│ │ └── types.ts
│ ├── server.ts
│ ├── services
│ │ └── conflict-detection
│ │ ├── config.ts
│ │ ├── ConflictAnalyzer.ts
│ │ ├── ConflictDetectionService.ts
│ │ ├── EventSimilarityChecker.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── tests
│ │ ├── integration
│ │ │ ├── claude-mcp-integration.test.ts
│ │ │ ├── direct-integration.test.ts
│ │ │ ├── docker-integration.test.ts
│ │ │ ├── openai-mcp-integration.test.ts
│ │ │ └── test-data-factory.ts
│ │ └── unit
│ │ ├── console-statements.test.ts
│ │ ├── handlers
│ │ │ ├── BatchListEvents.test.ts
│ │ │ ├── BatchRequestHandler.test.ts
│ │ │ ├── CalendarNameResolution.test.ts
│ │ │ ├── create-event-blocking.test.ts
│ │ │ ├── CreateEventHandler.test.ts
│ │ │ ├── datetime-utils.test.ts
│ │ │ ├── duplicate-event-display.test.ts
│ │ │ ├── GetCurrentTimeHandler.test.ts
│ │ │ ├── GetEventHandler.test.ts
│ │ │ ├── list-events-registry.test.ts
│ │ │ ├── ListEventsHandler.test.ts
│ │ │ ├── RecurringEventHelpers.test.ts
│ │ │ ├── UpdateEventHandler.recurring.test.ts
│ │ │ ├── UpdateEventHandler.test.ts
│ │ │ ├── utils-conflict-format.test.ts
│ │ │ └── utils.test.ts
│ │ ├── index.test.ts
│ │ ├── schemas
│ │ │ ├── enhanced-properties.test.ts
│ │ │ ├── no-refs.test.ts
│ │ │ ├── schema-compatibility.test.ts
│ │ │ ├── tool-registration.test.ts
│ │ │ └── validators.test.ts
│ │ ├── services
│ │ │ └── conflict-detection
│ │ │ ├── ConflictAnalyzer.test.ts
│ │ │ └── EventSimilarityChecker.test.ts
│ │ └── utils
│ │ ├── event-id-validator.test.ts
│ │ └── field-mask-builder.test.ts
│ ├── tools
│ │ └── registry.ts
│ ├── transports
│ │ ├── http.ts
│ │ └── stdio.ts
│ ├── types
│ │ └── structured-responses.ts
│ └── utils
│ ├── event-id-validator.ts
│ ├── field-mask-builder.ts
│ └── response-builder.ts
├── tsconfig.json
├── tsconfig.lint.json
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/src/tests/unit/handlers/UpdateEventHandler.recurring.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { OAuth2Client } from 'google-auth-library';
3 | import { calendar_v3 } from 'googleapis';
4 |
5 | // Enhanced UpdateEventHandler class that will be implemented
6 | class EnhancedUpdateEventHandler {
7 | private calendar: calendar_v3.Calendar;
8 |
9 | constructor(calendar: calendar_v3.Calendar) {
10 | this.calendar = calendar;
11 | }
12 |
13 | async runTool(args: any, oauth2Client: OAuth2Client): Promise<any> {
14 | // This would use the enhanced schema for validation
15 | const event = await this.updateEventWithScope(args);
16 | return {
17 | content: [{
18 | type: "text",
19 | text: `Event updated: ${event.summary} (${event.id})`,
20 | }],
21 | };
22 | }
23 |
24 | async updateEventWithScope(args: any): Promise<calendar_v3.Schema$Event> {
25 | const eventType = await this.detectEventType(args.eventId, args.calendarId);
26 |
27 | // Validate scope usage
28 | if (args.modificationScope !== 'all' && eventType !== 'recurring') {
29 | throw new Error('Scope other than "all" only applies to recurring events');
30 | }
31 |
32 | switch (args.modificationScope || 'all') {
33 | case 'single':
34 | return this.updateSingleInstance(args);
35 | case 'all':
36 | return this.updateAllInstances(args);
37 | case 'future':
38 | return this.updateFutureInstances(args);
39 | default:
40 | throw new Error(`Invalid modification scope: ${args.modificationScope}`);
41 | }
42 | }
43 |
44 | private async detectEventType(eventId: string, calendarId: string): Promise<'recurring' | 'single'> {
45 | const response = await this.calendar.events.get({
46 | calendarId,
47 | eventId
48 | });
49 |
50 | const event = response.data;
51 | return event.recurrence && event.recurrence.length > 0 ? 'recurring' : 'single';
52 | }
53 |
54 | async updateSingleInstance(args: any): Promise<calendar_v3.Schema$Event> {
55 | // Format instance ID: eventId_basicTimeFormat (convert to UTC first)
56 | const utcDate = new Date(args.originalStartTime);
57 | const basicTimeFormat = utcDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
58 | const instanceId = `${args.eventId}_${basicTimeFormat}`;
59 |
60 | const response = await this.calendar.events.patch({
61 | calendarId: args.calendarId,
62 | eventId: instanceId,
63 | requestBody: this.buildUpdateRequestBody(args)
64 | });
65 |
66 | if (!response.data) throw new Error('Failed to update event instance');
67 | return response.data;
68 | }
69 |
70 | async updateAllInstances(args: any): Promise<calendar_v3.Schema$Event> {
71 | const response = await this.calendar.events.patch({
72 | calendarId: args.calendarId,
73 | eventId: args.eventId,
74 | requestBody: this.buildUpdateRequestBody(args)
75 | });
76 |
77 | if (!response.data) throw new Error('Failed to update event');
78 | return response.data;
79 | }
80 |
81 | async updateFutureInstances(args: any): Promise<calendar_v3.Schema$Event> {
82 | // 1. Get original event
83 | const originalResponse = await this.calendar.events.get({
84 | calendarId: args.calendarId,
85 | eventId: args.eventId
86 | });
87 | const originalEvent = originalResponse.data;
88 |
89 | if (!originalEvent.recurrence) {
90 | throw new Error('Event does not have recurrence rules');
91 | }
92 |
93 | // 2. Calculate UNTIL date (one day before future start date)
94 | const futureDate = new Date(args.futureStartDate);
95 | const untilDate = new Date(futureDate.getTime() - 86400000); // -1 day
96 | const untilString = untilDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
97 |
98 | // 3. Update original event with UNTIL clause
99 | const updatedRRule = originalEvent.recurrence[0]
100 | .replace(/;UNTIL=\d{8}T\d{6}Z/g, '')
101 | .replace(/;COUNT=\d+/g, '') + `;UNTIL=${untilString}`;
102 |
103 | await this.calendar.events.patch({
104 | calendarId: args.calendarId,
105 | eventId: args.eventId,
106 | requestBody: { recurrence: [updatedRRule] }
107 | });
108 |
109 | // 4. Create new recurring event starting from future date
110 | const newEvent = {
111 | ...originalEvent,
112 | ...this.buildUpdateRequestBody(args),
113 | start: {
114 | dateTime: args.futureStartDate,
115 | timeZone: args.timeZone
116 | },
117 | end: {
118 | dateTime: this.calculateEndTime(args.futureStartDate, originalEvent),
119 | timeZone: args.timeZone
120 | }
121 | };
122 |
123 | // Clean fields that shouldn't be duplicated
124 | delete newEvent.id;
125 | delete newEvent.etag;
126 | delete newEvent.iCalUID;
127 | delete newEvent.created;
128 | delete newEvent.updated;
129 | delete newEvent.htmlLink;
130 | delete newEvent.hangoutLink;
131 |
132 | const response = await this.calendar.events.insert({
133 | calendarId: args.calendarId,
134 | requestBody: newEvent
135 | });
136 |
137 | if (!response.data) throw new Error('Failed to create new recurring event');
138 | return response.data;
139 | }
140 |
141 | private calculateEndTime(newStartTime: string, originalEvent: calendar_v3.Schema$Event): string {
142 | const newStart = new Date(newStartTime);
143 | const originalStart = new Date(originalEvent.start!.dateTime!);
144 | const originalEnd = new Date(originalEvent.end!.dateTime!);
145 | const duration = originalEnd.getTime() - originalStart.getTime();
146 |
147 | return new Date(newStart.getTime() + duration).toISOString();
148 | }
149 |
150 | private buildUpdateRequestBody(args: any): calendar_v3.Schema$Event {
151 | const requestBody: calendar_v3.Schema$Event = {};
152 |
153 | if (args.summary !== undefined && args.summary !== null) requestBody.summary = args.summary;
154 | if (args.description !== undefined && args.description !== null) requestBody.description = args.description;
155 | if (args.location !== undefined && args.location !== null) requestBody.location = args.location;
156 | if (args.colorId !== undefined && args.colorId !== null) requestBody.colorId = args.colorId;
157 | if (args.attendees !== undefined && args.attendees !== null) requestBody.attendees = args.attendees;
158 | if (args.reminders !== undefined && args.reminders !== null) requestBody.reminders = args.reminders;
159 | if (args.recurrence !== undefined && args.recurrence !== null) requestBody.recurrence = args.recurrence;
160 |
161 | // Handle time changes
162 | let timeChanged = false;
163 | if (args.start !== undefined && args.start !== null) {
164 | requestBody.start = { dateTime: args.start, timeZone: args.timeZone };
165 | timeChanged = true;
166 | }
167 | if (args.end !== undefined && args.end !== null) {
168 | requestBody.end = { dateTime: args.end, timeZone: args.timeZone };
169 | timeChanged = true;
170 | }
171 |
172 | // Only add timezone objects if there were actual time changes, OR if neither start/end provided but timezone is given
173 | if (timeChanged || (!args.start && !args.end && args.timeZone)) {
174 | if (!requestBody.start) requestBody.start = {};
175 | if (!requestBody.end) requestBody.end = {};
176 | if (!requestBody.start.timeZone) requestBody.start.timeZone = args.timeZone;
177 | if (!requestBody.end.timeZone) requestBody.end.timeZone = args.timeZone;
178 | }
179 |
180 | return requestBody;
181 | }
182 | }
183 |
184 | // Custom error class for recurring event errors
185 | class RecurringEventError extends Error {
186 | public code: string;
187 |
188 | constructor(message: string, code: string) {
189 | super(message);
190 | this.name = 'RecurringEventError';
191 | this.code = code;
192 | }
193 | }
194 |
195 | const ERRORS = {
196 | INVALID_SCOPE: 'INVALID_MODIFICATION_SCOPE',
197 | MISSING_ORIGINAL_TIME: 'MISSING_ORIGINAL_START_TIME',
198 | MISSING_FUTURE_DATE: 'MISSING_FUTURE_START_DATE',
199 | PAST_FUTURE_DATE: 'FUTURE_DATE_IN_PAST',
200 | NON_RECURRING_SCOPE: 'SCOPE_NOT_APPLICABLE_TO_SINGLE_EVENT'
201 | };
202 |
203 | describe('UpdateEventHandler - Recurring Events', () => {
204 | let handler: EnhancedUpdateEventHandler;
205 | let mockCalendar: any;
206 | let mockOAuth2Client: OAuth2Client;
207 |
208 | beforeEach(() => {
209 | mockCalendar = {
210 | events: {
211 | get: vi.fn(),
212 | patch: vi.fn(),
213 | insert: vi.fn()
214 | }
215 | };
216 | handler = new EnhancedUpdateEventHandler(mockCalendar);
217 | mockOAuth2Client = {} as OAuth2Client;
218 | });
219 |
220 | describe('updateEventWithScope', () => {
221 | it('should detect event type and route to appropriate method', async () => {
222 | const recurringEvent = {
223 | data: {
224 | id: 'recurring123',
225 | summary: 'Weekly Meeting',
226 | recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO']
227 | }
228 | };
229 | mockCalendar.events.get.mockResolvedValue(recurringEvent);
230 | mockCalendar.events.patch.mockResolvedValue({ data: recurringEvent.data });
231 |
232 | const args = {
233 | calendarId: 'primary',
234 | eventId: 'recurring123',
235 | timeZone: 'America/Los_Angeles',
236 | modificationScope: 'all',
237 | summary: 'Updated Meeting'
238 | };
239 |
240 | await handler.updateEventWithScope(args);
241 |
242 | expect(mockCalendar.events.get).toHaveBeenCalledWith({
243 | calendarId: 'primary',
244 | eventId: 'recurring123'
245 | });
246 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
247 | calendarId: 'primary',
248 | eventId: 'recurring123',
249 | requestBody: expect.objectContaining({
250 | summary: 'Updated Meeting'
251 | })
252 | });
253 | });
254 |
255 | it('should throw error when using non-"all" scope on single events', async () => {
256 | const singleEvent = {
257 | data: {
258 | id: 'single123',
259 | summary: 'One-time Meeting'
260 | // no recurrence
261 | }
262 | };
263 | mockCalendar.events.get.mockResolvedValue(singleEvent);
264 |
265 | const args = {
266 | calendarId: 'primary',
267 | eventId: 'single123',
268 | timeZone: 'America/Los_Angeles',
269 | modificationScope: 'single',
270 | originalStartTime: '2024-06-15T10:00:00-07:00'
271 | };
272 |
273 | await expect(handler.updateEventWithScope(args))
274 | .rejects.toThrow('Scope other than "all" only applies to recurring events');
275 | });
276 |
277 | it('should default to "all" scope when not specified', async () => {
278 | const recurringEvent = {
279 | data: {
280 | id: 'recurring123',
281 | recurrence: ['RRULE:FREQ=WEEKLY']
282 | }
283 | };
284 | mockCalendar.events.get.mockResolvedValue(recurringEvent);
285 | mockCalendar.events.patch.mockResolvedValue({ data: recurringEvent.data });
286 |
287 | const args = {
288 | calendarId: 'primary',
289 | eventId: 'recurring123',
290 | timeZone: 'UTC',
291 | summary: 'Updated Meeting'
292 | // no modificationScope specified
293 | };
294 |
295 | await handler.updateEventWithScope(args);
296 |
297 | // Should call updateAllInstances (patch with master event ID)
298 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
299 | calendarId: 'primary',
300 | eventId: 'recurring123',
301 | requestBody: expect.any(Object)
302 | });
303 | });
304 | });
305 |
306 | describe('updateSingleInstance', () => {
307 | it('should format instance ID correctly and patch specific instance', async () => {
308 | const mockInstanceEvent = {
309 | data: {
310 | id: 'recurring123_20240615T170000Z',
311 | summary: 'Updated Instance'
312 | }
313 | };
314 | mockCalendar.events.patch.mockResolvedValue(mockInstanceEvent);
315 |
316 | const args = {
317 | calendarId: 'primary',
318 | eventId: 'recurring123',
319 | timeZone: 'America/Los_Angeles',
320 | modificationScope: 'single',
321 | originalStartTime: '2024-06-15T10:00:00-07:00',
322 | summary: 'Updated Instance'
323 | };
324 |
325 | const result = await handler.updateSingleInstance(args);
326 |
327 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
328 | calendarId: 'primary',
329 | eventId: 'recurring123_20240615T170000Z',
330 | requestBody: expect.objectContaining({
331 | summary: 'Updated Instance'
332 | })
333 | });
334 | expect(result.summary).toBe('Updated Instance');
335 | });
336 |
337 | it('should handle different timezone formats in originalStartTime', async () => {
338 | const testCases = [
339 | {
340 | originalStartTime: '2024-06-15T10:00:00Z',
341 | expectedInstanceId: 'event123_20240615T100000Z'
342 | },
343 | {
344 | originalStartTime: '2024-06-15T10:00:00+05:30',
345 | expectedInstanceId: 'event123_20240615T043000Z'
346 | },
347 | {
348 | originalStartTime: '2024-06-15T10:00:00.000-08:00',
349 | expectedInstanceId: 'event123_20240615T180000Z'
350 | }
351 | ];
352 |
353 | for (const testCase of testCases) {
354 | mockCalendar.events.patch.mockClear();
355 | mockCalendar.events.patch.mockResolvedValue({ data: { id: testCase.expectedInstanceId } });
356 |
357 | const args = {
358 | calendarId: 'primary',
359 | eventId: 'event123',
360 | timeZone: 'UTC',
361 | originalStartTime: testCase.originalStartTime,
362 | summary: 'Test'
363 | };
364 |
365 | await handler.updateSingleInstance(args);
366 |
367 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
368 | calendarId: 'primary',
369 | eventId: testCase.expectedInstanceId,
370 | requestBody: expect.any(Object)
371 | });
372 | }
373 | });
374 |
375 | it('should throw error if patch fails', async () => {
376 | mockCalendar.events.patch.mockResolvedValue({ data: null });
377 |
378 | const args = {
379 | calendarId: 'primary',
380 | eventId: 'recurring123',
381 | originalStartTime: '2024-06-15T10:00:00Z',
382 | timeZone: 'UTC'
383 | };
384 |
385 | await expect(handler.updateSingleInstance(args))
386 | .rejects.toThrow('Failed to update event instance');
387 | });
388 | });
389 |
390 | describe('updateAllInstances', () => {
391 | it('should patch master event with all modifications', async () => {
392 | const mockUpdatedEvent = {
393 | data: {
394 | id: 'recurring123',
395 | summary: 'Updated Weekly Meeting',
396 | location: 'New Conference Room'
397 | }
398 | };
399 | mockCalendar.events.patch.mockResolvedValue(mockUpdatedEvent);
400 |
401 | const args = {
402 | calendarId: 'primary',
403 | eventId: 'recurring123',
404 | timeZone: 'America/Los_Angeles',
405 | modificationScope: 'all',
406 | summary: 'Updated Weekly Meeting',
407 | location: 'New Conference Room',
408 | colorId: '9'
409 | };
410 |
411 | const result = await handler.updateAllInstances(args);
412 |
413 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
414 | calendarId: 'primary',
415 | eventId: 'recurring123',
416 | requestBody: expect.objectContaining({
417 | summary: 'Updated Weekly Meeting',
418 | location: 'New Conference Room',
419 | colorId: '9'
420 | })
421 | });
422 | expect(result.summary).toBe('Updated Weekly Meeting');
423 | });
424 |
425 | it('should handle timezone changes for recurring events', async () => {
426 | const mockEvent = { data: { id: 'recurring123' } };
427 | mockCalendar.events.patch.mockResolvedValue(mockEvent);
428 |
429 | const args = {
430 | calendarId: 'primary',
431 | eventId: 'recurring123',
432 | timeZone: 'Europe/London',
433 | start: '2024-06-15T09:00:00+01:00',
434 | end: '2024-06-15T10:00:00+01:00'
435 | };
436 |
437 | await handler.updateAllInstances(args);
438 |
439 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
440 | calendarId: 'primary',
441 | eventId: 'recurring123',
442 | requestBody: expect.objectContaining({
443 | start: {
444 | dateTime: '2024-06-15T09:00:00+01:00',
445 | timeZone: 'Europe/London'
446 | },
447 | end: {
448 | dateTime: '2024-06-15T10:00:00+01:00',
449 | timeZone: 'Europe/London'
450 | }
451 | })
452 | });
453 | });
454 | });
455 |
456 | describe('updateFutureInstances', () => {
457 | it('should split recurring series correctly', async () => {
458 | const originalEvent = {
459 | data: {
460 | id: 'recurring123',
461 | summary: 'Weekly Meeting',
462 | start: { dateTime: '2024-06-01T10:00:00-07:00' },
463 | end: { dateTime: '2024-06-01T11:00:00-07:00' },
464 | recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=20'],
465 | attendees: [{ email: '[email protected]' }]
466 | }
467 | };
468 |
469 | mockCalendar.events.get.mockResolvedValue(originalEvent);
470 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
471 |
472 | const newEvent = {
473 | data: {
474 | id: 'new_recurring456',
475 | summary: 'Updated Future Meeting'
476 | }
477 | };
478 | mockCalendar.events.insert.mockResolvedValue(newEvent);
479 |
480 | const args = {
481 | calendarId: 'primary',
482 | eventId: 'recurring123',
483 | timeZone: 'America/Los_Angeles',
484 | modificationScope: 'future',
485 | futureStartDate: '2024-06-15T10:00:00-07:00',
486 | summary: 'Updated Future Meeting',
487 | location: 'New Location'
488 | };
489 |
490 | const result = await handler.updateFutureInstances(args);
491 |
492 | // Should update original event with UNTIL clause
493 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
494 | calendarId: 'primary',
495 | eventId: 'recurring123',
496 | requestBody: {
497 | recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240614T170000Z']
498 | }
499 | });
500 |
501 | // Should create new recurring event
502 | expect(mockCalendar.events.insert).toHaveBeenCalledWith({
503 | calendarId: 'primary',
504 | requestBody: expect.objectContaining({
505 | summary: 'Updated Future Meeting',
506 | location: 'New Location',
507 | start: {
508 | dateTime: '2024-06-15T10:00:00-07:00',
509 | timeZone: 'America/Los_Angeles'
510 | },
511 | end: {
512 | dateTime: expect.any(String),
513 | timeZone: 'America/Los_Angeles'
514 | },
515 | attendees: [{ email: '[email protected]' }]
516 | })
517 | });
518 |
519 | // Should not include system fields
520 | const insertCall = mockCalendar.events.insert.mock.calls[0][0];
521 | expect(insertCall.requestBody.id).toBeUndefined();
522 | expect(insertCall.requestBody.etag).toBeUndefined();
523 | expect(insertCall.requestBody.iCalUID).toBeUndefined();
524 |
525 | expect(result.summary).toBe('Updated Future Meeting');
526 | });
527 |
528 | it('should calculate end time correctly based on original duration', async () => {
529 | const originalEvent = {
530 | data: {
531 | id: 'recurring123',
532 | start: { dateTime: '2024-06-01T10:00:00-07:00' },
533 | end: { dateTime: '2024-06-01T12:30:00-07:00' }, // 2.5 hour duration
534 | recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO']
535 | }
536 | };
537 |
538 | mockCalendar.events.get.mockResolvedValue(originalEvent);
539 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
540 | mockCalendar.events.insert.mockResolvedValue({ data: {} });
541 |
542 | const args = {
543 | calendarId: 'primary',
544 | eventId: 'recurring123',
545 | timeZone: 'America/Los_Angeles',
546 | futureStartDate: '2024-06-15T14:00:00-07:00'
547 | };
548 |
549 | await handler.updateFutureInstances(args);
550 |
551 | const insertCall = mockCalendar.events.insert.mock.calls[0][0];
552 | const endDateTime = new Date(insertCall.requestBody.end.dateTime);
553 | const startDateTime = new Date(insertCall.requestBody.start.dateTime);
554 | const duration = endDateTime.getTime() - startDateTime.getTime();
555 |
556 | // Should maintain 2.5 hour duration (9000000 ms)
557 | expect(duration).toBe(2.5 * 60 * 60 * 1000);
558 | });
559 |
560 | it('should handle events without recurrence', async () => {
561 | const singleEvent = {
562 | data: {
563 | id: 'single123',
564 | summary: 'One-time Meeting'
565 | // no recurrence
566 | }
567 | };
568 |
569 | mockCalendar.events.get.mockResolvedValue(singleEvent);
570 |
571 | const args = {
572 | calendarId: 'primary',
573 | eventId: 'single123',
574 | futureStartDate: '2024-06-15T10:00:00-07:00',
575 | timeZone: 'UTC'
576 | };
577 |
578 | await expect(handler.updateFutureInstances(args))
579 | .rejects.toThrow('Event does not have recurrence rules');
580 | });
581 |
582 | it('should handle existing UNTIL and COUNT clauses correctly', async () => {
583 | const testCases = [
584 | {
585 | original: 'RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240531T170000Z',
586 | expected: 'RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240614T170000Z'
587 | },
588 | {
589 | original: 'RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=10',
590 | expected: 'RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20240614T170000Z'
591 | },
592 | {
593 | original: 'RRULE:FREQ=DAILY;INTERVAL=2;COUNT=15;BYHOUR=10',
594 | expected: 'RRULE:FREQ=DAILY;INTERVAL=2;BYHOUR=10;UNTIL=20240614T170000Z'
595 | }
596 | ];
597 |
598 | for (const testCase of testCases) {
599 | const originalEvent = {
600 | data: {
601 | id: 'test',
602 | start: { dateTime: '2024-06-01T10:00:00-07:00' },
603 | end: { dateTime: '2024-06-01T11:00:00-07:00' },
604 | recurrence: [testCase.original]
605 | }
606 | };
607 |
608 | mockCalendar.events.get.mockResolvedValue(originalEvent);
609 | mockCalendar.events.patch.mockClear();
610 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
611 | mockCalendar.events.insert.mockResolvedValue({ data: {} });
612 |
613 | const args = {
614 | calendarId: 'primary',
615 | eventId: 'test',
616 | futureStartDate: '2024-06-15T10:00:00-07:00',
617 | timeZone: 'America/Los_Angeles'
618 | };
619 |
620 | await handler.updateFutureInstances(args);
621 |
622 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
623 | calendarId: 'primary',
624 | eventId: 'test',
625 | requestBody: {
626 | recurrence: [testCase.expected]
627 | }
628 | });
629 | }
630 | });
631 | });
632 |
633 | describe('Error Handling', () => {
634 | it('should handle Google API errors gracefully', async () => {
635 | mockCalendar.events.get.mockRejectedValue(new Error('Event not found'));
636 |
637 | const args = {
638 | calendarId: 'primary',
639 | eventId: 'nonexistent',
640 | timeZone: 'UTC'
641 | };
642 |
643 | await expect(handler.updateEventWithScope(args))
644 | .rejects.toThrow('Event not found');
645 | });
646 |
647 | it('should handle patch failures for single instances', async () => {
648 | mockCalendar.events.patch.mockRejectedValue(new Error('Instance not found'));
649 |
650 | const args = {
651 | calendarId: 'primary',
652 | eventId: 'recurring123',
653 | originalStartTime: '2024-06-15T10:00:00Z',
654 | timeZone: 'UTC'
655 | };
656 |
657 | await expect(handler.updateSingleInstance(args))
658 | .rejects.toThrow('Instance not found');
659 | });
660 |
661 | it('should handle insert failures for future instances', async () => {
662 | const originalEvent = {
663 | data: {
664 | id: 'recurring123',
665 | start: { dateTime: '2024-06-01T10:00:00Z' },
666 | end: { dateTime: '2024-06-01T11:00:00Z' },
667 | recurrence: ['RRULE:FREQ=WEEKLY']
668 | }
669 | };
670 |
671 | mockCalendar.events.get.mockResolvedValue(originalEvent);
672 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
673 | mockCalendar.events.insert.mockResolvedValue({ data: null });
674 |
675 | const args = {
676 | calendarId: 'primary',
677 | eventId: 'recurring123',
678 | futureStartDate: '2024-06-15T10:00:00Z',
679 | timeZone: 'UTC'
680 | };
681 |
682 | await expect(handler.updateFutureInstances(args))
683 | .rejects.toThrow('Failed to create new recurring event');
684 | });
685 | });
686 |
687 | describe('Integration with Tool Framework', () => {
688 | it('should return proper response format from runTool', async () => {
689 | const mockEvent = {
690 | data: {
691 | id: 'event123',
692 | summary: 'Updated Meeting',
693 | recurrence: ['RRULE:FREQ=WEEKLY']
694 | }
695 | };
696 |
697 | mockCalendar.events.get.mockResolvedValue(mockEvent);
698 | mockCalendar.events.patch.mockResolvedValue(mockEvent);
699 |
700 | const args = {
701 | calendarId: 'primary',
702 | eventId: 'event123',
703 | timeZone: 'UTC',
704 | summary: 'Updated Meeting'
705 | };
706 |
707 | const result = await handler.runTool(args, mockOAuth2Client);
708 |
709 | expect(result).toEqual({
710 | content: [{
711 | type: "text",
712 | text: "Event updated: Updated Meeting (event123)"
713 | }]
714 | });
715 | });
716 | });
717 |
718 | describe('Edge Cases and Additional Scenarios', () => {
719 | it('should handle events with complex recurrence patterns', async () => {
720 | const complexRecurringEvent = {
721 | data: {
722 | id: 'complex123',
723 | summary: 'Complex Meeting',
724 | start: { dateTime: '2024-06-01T10:00:00Z' },
725 | end: { dateTime: '2024-06-01T11:00:00Z' },
726 | recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;BYHOUR=10;BYMINUTE=0']
727 | }
728 | };
729 |
730 | mockCalendar.events.get.mockResolvedValue(complexRecurringEvent);
731 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
732 | mockCalendar.events.insert.mockResolvedValue({ data: { id: 'new_complex456' } });
733 |
734 | const args = {
735 | calendarId: 'primary',
736 | eventId: 'complex123',
737 | timeZone: 'UTC',
738 | modificationScope: 'future',
739 | futureStartDate: '2024-06-15T10:00:00Z',
740 | summary: 'Updated Complex Meeting'
741 | };
742 |
743 | const result = await handler.updateFutureInstances(args);
744 |
745 | // Should handle complex recurrence rules correctly
746 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
747 | calendarId: 'primary',
748 | eventId: 'complex123',
749 | requestBody: {
750 | recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;BYHOUR=10;BYMINUTE=0;UNTIL=20240614T100000Z']
751 | }
752 | });
753 | });
754 |
755 | it('should handle timezone changes across DST boundaries', async () => {
756 | const mockEvent = { data: { id: 'dst123' } };
757 | mockCalendar.events.patch.mockResolvedValue(mockEvent);
758 |
759 | const args = {
760 | calendarId: 'primary',
761 | eventId: 'dst123',
762 | timeZone: 'America/New_York',
763 | modificationScope: 'all',
764 | start: '2024-03-10T07:00:00-05:00', // DST transition date
765 | end: '2024-03-10T08:00:00-05:00'
766 | };
767 |
768 | await handler.updateAllInstances(args);
769 |
770 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
771 | calendarId: 'primary',
772 | eventId: 'dst123',
773 | requestBody: expect.objectContaining({
774 | start: {
775 | dateTime: '2024-03-10T07:00:00-05:00',
776 | timeZone: 'America/New_York'
777 | },
778 | end: {
779 | dateTime: '2024-03-10T08:00:00-05:00',
780 | timeZone: 'America/New_York'
781 | }
782 | })
783 | });
784 | });
785 |
786 | it('should handle very long recurrence series', async () => {
787 | const longRecurringEvent = {
788 | data: {
789 | id: 'long123',
790 | start: { dateTime: '2024-01-01T10:00:00Z' },
791 | end: { dateTime: '2024-01-01T11:00:00Z' },
792 | recurrence: ['RRULE:FREQ=DAILY;COUNT=365'] // Daily for a year
793 | }
794 | };
795 |
796 | mockCalendar.events.get.mockResolvedValue(longRecurringEvent);
797 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
798 | mockCalendar.events.insert.mockResolvedValue({ data: { id: 'new_long456' } });
799 |
800 | const args = {
801 | calendarId: 'primary',
802 | eventId: 'long123',
803 | timeZone: 'UTC',
804 | modificationScope: 'future',
805 | futureStartDate: '2024-06-01T10:00:00Z'
806 | };
807 |
808 | await handler.updateFutureInstances(args);
809 |
810 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
811 | calendarId: 'primary',
812 | eventId: 'long123',
813 | requestBody: {
814 | recurrence: ['RRULE:FREQ=DAILY;UNTIL=20240531T100000Z']
815 | }
816 | });
817 | });
818 |
819 | it('should handle events with multiple recurrence rules', async () => {
820 | const multiRuleEvent = {
821 | data: {
822 | id: 'multi123',
823 | start: { dateTime: '2024-06-01T10:00:00Z' },
824 | end: { dateTime: '2024-06-01T11:00:00Z' },
825 | recurrence: [
826 | 'RRULE:FREQ=WEEKLY;BYDAY=MO',
827 | 'EXDATE:20240610T100000Z' // Exception date
828 | ]
829 | }
830 | };
831 |
832 | mockCalendar.events.get.mockResolvedValue(multiRuleEvent);
833 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
834 | mockCalendar.events.insert.mockResolvedValue({ data: { id: 'new_multi456' } });
835 |
836 | const args = {
837 | calendarId: 'primary',
838 | eventId: 'multi123',
839 | timeZone: 'UTC',
840 | modificationScope: 'future',
841 | futureStartDate: '2024-06-15T10:00:00Z'
842 | };
843 |
844 | await handler.updateFutureInstances(args);
845 |
846 | // Should preserve exception dates in new event
847 | const insertCall = mockCalendar.events.insert.mock.calls[0][0];
848 | expect(insertCall.requestBody.recurrence).toContain('EXDATE:20240610T100000Z');
849 | });
850 |
851 | it('should handle instance ID formatting with milliseconds and various timezones', async () => {
852 | const testCases = [
853 | {
854 | originalStartTime: '2024-06-15T10:00:00.123-07:00',
855 | expectedInstanceId: 'event123_20240615T170000Z'
856 | },
857 | {
858 | originalStartTime: '2024-12-31T23:59:59.999+14:00',
859 | expectedInstanceId: 'event123_20241231T095959Z'
860 | },
861 | {
862 | originalStartTime: '2024-06-15T00:00:00.000-12:00',
863 | expectedInstanceId: 'event123_20240615T120000Z'
864 | }
865 | ];
866 |
867 | for (const testCase of testCases) {
868 | mockCalendar.events.patch.mockClear();
869 | mockCalendar.events.patch.mockResolvedValue({ data: { id: testCase.expectedInstanceId } });
870 |
871 | const args = {
872 | calendarId: 'primary',
873 | eventId: 'event123',
874 | timeZone: 'UTC',
875 | originalStartTime: testCase.originalStartTime,
876 | summary: 'Test'
877 | };
878 |
879 | await handler.updateSingleInstance(args);
880 |
881 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
882 | calendarId: 'primary',
883 | eventId: testCase.expectedInstanceId,
884 | requestBody: expect.any(Object)
885 | });
886 | }
887 | });
888 |
889 | it('should handle empty or minimal event data gracefully', async () => {
890 | const minimalEvent = {
891 | data: {
892 | id: 'minimal123',
893 | start: { dateTime: '2024-06-01T10:00:00Z' },
894 | end: { dateTime: '2024-06-01T11:00:00Z' },
895 | recurrence: ['RRULE:FREQ=WEEKLY']
896 | // No summary, description, attendees, etc.
897 | }
898 | };
899 |
900 | mockCalendar.events.get.mockResolvedValue(minimalEvent);
901 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
902 | mockCalendar.events.insert.mockResolvedValue({ data: { id: 'new_minimal456' } });
903 |
904 | const args = {
905 | calendarId: 'primary',
906 | eventId: 'minimal123',
907 | timeZone: 'UTC',
908 | modificationScope: 'future',
909 | futureStartDate: '2024-06-15T10:00:00Z',
910 | summary: 'Added Summary'
911 | };
912 |
913 | const result = await handler.updateFutureInstances(args);
914 |
915 | const insertCall = mockCalendar.events.insert.mock.calls[0][0];
916 | expect(insertCall.requestBody.summary).toBe('Added Summary');
917 | expect(insertCall.requestBody.id).toBeUndefined();
918 | });
919 | });
920 |
921 | describe('Validation and Error Edge Cases', () => {
922 | it('should handle malformed recurrence rules gracefully', async () => {
923 | const malformedEvent = {
924 | data: {
925 | id: 'malformed123',
926 | start: { dateTime: '2024-06-01T10:00:00Z' },
927 | end: { dateTime: '2024-06-01T11:00:00Z' },
928 | recurrence: ['INVALID_RRULE_FORMAT']
929 | }
930 | };
931 |
932 | mockCalendar.events.get.mockResolvedValue(malformedEvent);
933 |
934 | const args = {
935 | calendarId: 'primary',
936 | eventId: 'malformed123',
937 | timeZone: 'UTC',
938 | modificationScope: 'future',
939 | futureStartDate: '2024-06-15T10:00:00Z'
940 | };
941 |
942 | // Should still attempt to process, letting Google Calendar API handle validation
943 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
944 | mockCalendar.events.insert.mockResolvedValue({ data: { id: 'new123' } });
945 |
946 | await handler.updateFutureInstances(args);
947 |
948 | expect(mockCalendar.events.patch).toHaveBeenCalled();
949 | });
950 |
951 | it('should handle network timeouts and retries', async () => {
952 | mockCalendar.events.get.mockRejectedValueOnce(new Error('Network timeout'))
953 | .mockResolvedValue({
954 | data: {
955 | id: 'retry123',
956 | recurrence: ['RRULE:FREQ=WEEKLY']
957 | }
958 | });
959 |
960 | const args = {
961 | calendarId: 'primary',
962 | eventId: 'retry123',
963 | timeZone: 'UTC'
964 | };
965 |
966 | // First call should fail, but we're testing that the error propagates correctly
967 | await expect(handler.updateEventWithScope(args))
968 | .rejects.toThrow('Network timeout');
969 | });
970 |
971 | it('should validate scope restrictions on single events', async () => {
972 | const singleEvent = {
973 | data: {
974 | id: 'single123',
975 | summary: 'One-time Meeting'
976 | // no recurrence
977 | }
978 | };
979 | mockCalendar.events.get.mockResolvedValue(singleEvent);
980 |
981 | const invalidScopes = ['single', 'future'];
982 |
983 | for (const scope of invalidScopes) {
984 | const args = {
985 | calendarId: 'primary',
986 | eventId: 'single123',
987 | timeZone: 'UTC',
988 | modificationScope: scope,
989 | originalStartTime: '2024-06-15T10:00:00Z',
990 | futureStartDate: '2024-06-20T10:00:00Z'
991 | };
992 |
993 | await expect(handler.updateEventWithScope(args))
994 | .rejects.toThrow('Scope other than "all" only applies to recurring events');
995 | }
996 | });
997 | });
998 | });
```
--------------------------------------------------------------------------------
/src/tools/registry.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { z } from "zod";
3 | import { zodToJsonSchema } from "zod-to-json-schema";
4 | import { BaseToolHandler } from "../handlers/core/BaseToolHandler.js";
5 | import { ALLOWED_EVENT_FIELDS } from "../utils/field-mask-builder.js";
6 |
7 | // Import all handlers
8 | import { ListCalendarsHandler } from "../handlers/core/ListCalendarsHandler.js";
9 | import { ListEventsHandler } from "../handlers/core/ListEventsHandler.js";
10 | import { SearchEventsHandler } from "../handlers/core/SearchEventsHandler.js";
11 | import { GetEventHandler } from "../handlers/core/GetEventHandler.js";
12 | import { ListColorsHandler } from "../handlers/core/ListColorsHandler.js";
13 | import { CreateEventHandler } from "../handlers/core/CreateEventHandler.js";
14 | import { UpdateEventHandler } from "../handlers/core/UpdateEventHandler.js";
15 | import { DeleteEventHandler } from "../handlers/core/DeleteEventHandler.js";
16 | import { FreeBusyEventHandler } from "../handlers/core/FreeBusyEventHandler.js";
17 | import { GetCurrentTimeHandler } from "../handlers/core/GetCurrentTimeHandler.js";
18 |
19 | // Define shared schema fields for reuse
20 | // Note: Event datetime fields (start/end) are NOT shared to avoid $ref generation
21 | // Each tool defines its own inline schemas for these fields
22 |
23 | const timeMinSchema = z.string()
24 | .refine((val) => {
25 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
26 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
27 | return withTimezone || withoutTimezone;
28 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
29 | .describe("Start time boundary. Preferred: '2024-01-01T00:00:00' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T00:00:00Z' or '2024-01-01T00:00:00-08:00'.")
30 | .optional();
31 |
32 | const timeMaxSchema = z.string()
33 | .refine((val) => {
34 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
35 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
36 | return withTimezone || withoutTimezone;
37 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
38 | .describe("End time boundary. Preferred: '2024-01-01T23:59:59' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T23:59:59Z' or '2024-01-01T23:59:59-08:00'.")
39 | .optional();
40 |
41 | const timeZoneSchema = z.string().optional().describe(
42 | "Timezone as IANA Time Zone Database name (e.g., America/Los_Angeles). Takes priority over calendar's default timezone. Only used for timezone-naive datetime strings."
43 | );
44 |
45 | const fieldsSchema = z.array(z.enum(ALLOWED_EVENT_FIELDS)).optional().describe(
46 | "Optional array of additional event fields to retrieve. Available fields are strictly validated. Default fields (id, summary, start, end, status, htmlLink, location, attendees) are always included."
47 | );
48 |
49 | const privateExtendedPropertySchema = z
50 | .array(z.string().regex(/^[^=]+=[^=]+$/, "Must be in key=value format"))
51 | .optional()
52 | .describe(
53 | "Filter by private extended properties (key=value). Matches events that have all specified properties."
54 | );
55 |
56 | const sharedExtendedPropertySchema = z
57 | .array(z.string().regex(/^[^=]+=[^=]+$/, "Must be in key=value format"))
58 | .optional()
59 | .describe(
60 | "Filter by shared extended properties (key=value). Matches events that have all specified properties."
61 | );
62 |
63 | // Define all tool schemas with TypeScript inference
64 | export const ToolSchemas = {
65 | 'list-calendars': z.object({}),
66 |
67 | 'list-events': z.object({
68 | calendarId: z.union([
69 | z.string().describe(
70 | "Calendar identifier(s) to query. Accepts calendar IDs (e.g., 'primary', '[email protected]') OR calendar names (e.g., 'Work', 'Personal'). Single calendar: 'primary'. Multiple calendars: array ['Work', 'Personal'] or JSON string '[\"Work\", \"Personal\"]'"
71 | ),
72 | z.array(z.string().min(1))
73 | .min(1, "At least one calendar ID is required")
74 | .max(50, "Maximum 50 calendars allowed per request")
75 | .refine(
76 | (arr) => new Set(arr).size === arr.length,
77 | "Duplicate calendar IDs are not allowed"
78 | )
79 | .describe("Array of calendar IDs to query events from (max 50, no duplicates)")
80 | ]),
81 | timeMin: timeMinSchema,
82 | timeMax: timeMaxSchema,
83 | timeZone: timeZoneSchema,
84 | fields: fieldsSchema,
85 | privateExtendedProperty: privateExtendedPropertySchema,
86 | sharedExtendedProperty: sharedExtendedPropertySchema
87 | }),
88 |
89 | 'search-events': z.object({
90 | calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"),
91 | query: z.string().describe(
92 | "Free text search query (searches summary, description, location, attendees, etc.)"
93 | ),
94 | timeMin: z.string()
95 | .refine((val) => {
96 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
97 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
98 | return withTimezone || withoutTimezone;
99 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
100 | .describe("Start time boundary. Preferred: '2024-01-01T00:00:00' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T00:00:00Z' or '2024-01-01T00:00:00-08:00'."),
101 | timeMax: z.string()
102 | .refine((val) => {
103 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
104 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
105 | return withTimezone || withoutTimezone;
106 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
107 | .describe("End time boundary. Preferred: '2024-01-01T23:59:59' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T23:59:59Z' or '2024-01-01T23:59:59-08:00'."),
108 | timeZone: z.string().optional().describe(
109 | "Timezone as IANA Time Zone Database name (e.g., America/Los_Angeles). Takes priority over calendar's default timezone. Only used for timezone-naive datetime strings."
110 | ),
111 | fields: z.array(z.enum(ALLOWED_EVENT_FIELDS)).optional().describe(
112 | "Optional array of additional event fields to retrieve. Available fields are strictly validated. Default fields (id, summary, start, end, status, htmlLink, location, attendees) are always included."
113 | ),
114 | privateExtendedProperty: z
115 | .array(z.string().regex(/^[^=]+=[^=]+$/, "Must be in key=value format"))
116 | .optional()
117 | .describe(
118 | "Filter by private extended properties (key=value). Matches events that have all specified properties."
119 | ),
120 | sharedExtendedProperty: z
121 | .array(z.string().regex(/^[^=]+=[^=]+$/, "Must be in key=value format"))
122 | .optional()
123 | .describe(
124 | "Filter by shared extended properties (key=value). Matches events that have all specified properties."
125 | )
126 | }),
127 |
128 | 'get-event': z.object({
129 | calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"),
130 | eventId: z.string().describe("ID of the event to retrieve"),
131 | fields: z.array(z.enum(ALLOWED_EVENT_FIELDS)).optional().describe(
132 | "Optional array of additional event fields to retrieve. Available fields are strictly validated. Default fields (id, summary, start, end, status, htmlLink, location, attendees) are always included."
133 | )
134 | }),
135 |
136 | 'list-colors': z.object({}),
137 |
138 | 'create-event': z.object({
139 | calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"),
140 | eventId: z.string().optional().describe("Optional custom event ID (5-1024 characters, base32hex encoding: lowercase letters a-v and digits 0-9 only). If not provided, Google Calendar will generate one."),
141 | summary: z.string().describe("Title of the event"),
142 | description: z.string().optional().describe("Description/notes for the event"),
143 | start: z.string()
144 | .refine((val) => {
145 | const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(val);
146 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
147 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
148 | return dateOnly || withTimezone || withoutTimezone;
149 | }, "Must be ISO 8601 format: '2025-01-01T10:00:00' for timed events or '2025-01-01' for all-day events")
150 | .describe("Event start time: '2025-01-01T10:00:00' for timed events or '2025-01-01' for all-day events. Also accepts Google Calendar API object format: {date: '2025-01-01'} or {dateTime: '2025-01-01T10:00:00', timeZone: 'America/Los_Angeles'}"),
151 | end: z.string()
152 | .refine((val) => {
153 | const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(val);
154 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
155 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
156 | return dateOnly || withTimezone || withoutTimezone;
157 | }, "Must be ISO 8601 format: '2025-01-01T11:00:00' for timed events or '2025-01-02' for all-day events")
158 | .describe("Event end time: '2025-01-01T11:00:00' for timed events or '2025-01-02' for all-day events (exclusive). Also accepts Google Calendar API object format: {date: '2025-01-02'} or {dateTime: '2025-01-01T11:00:00', timeZone: 'America/Los_Angeles'}"),
159 | timeZone: z.string().optional().describe(
160 | "Timezone as IANA Time Zone Database name (e.g., America/Los_Angeles). Takes priority over calendar's default timezone. Only used for timezone-naive datetime strings."
161 | ),
162 | location: z.string().optional().describe("Location of the event"),
163 | attendees: z.array(z.object({
164 | email: z.string().email().describe("Email address of the attendee"),
165 | displayName: z.string().optional().describe("Display name of the attendee"),
166 | optional: z.boolean().optional().describe("Whether this is an optional attendee"),
167 | responseStatus: z.enum(["needsAction", "declined", "tentative", "accepted"]).optional().describe("Attendee's response status"),
168 | comment: z.string().optional().describe("Attendee's response comment"),
169 | additionalGuests: z.number().int().min(0).optional().describe("Number of additional guests the attendee is bringing")
170 | })).optional().describe("List of event attendees with their details"),
171 | colorId: z.string().optional().describe(
172 | "Color ID for the event (use list-colors to see available IDs)"
173 | ),
174 | reminders: z.object({
175 | useDefault: z.boolean().describe("Whether to use the default reminders"),
176 | overrides: z.array(z.object({
177 | method: z.enum(["email", "popup"]).default("popup").describe("Reminder method"),
178 | minutes: z.number().describe("Minutes before the event to trigger the reminder")
179 | }).partial({ method: true })).optional().describe("Custom reminders")
180 | }).describe("Reminder settings for the event").optional(),
181 | recurrence: z.array(z.string()).optional().describe(
182 | "Recurrence rules in RFC5545 format (e.g., [\"RRULE:FREQ=WEEKLY;COUNT=5\"])"
183 | ),
184 | transparency: z.enum(["opaque", "transparent"]).optional().describe(
185 | "Whether the event blocks time on the calendar. 'opaque' means busy, 'transparent' means free."
186 | ),
187 | visibility: z.enum(["default", "public", "private", "confidential"]).optional().describe(
188 | "Visibility of the event. Use 'public' for public events, 'private' for private events visible to attendees."
189 | ),
190 | guestsCanInviteOthers: z.boolean().optional().describe(
191 | "Whether attendees can invite others to the event. Default is true."
192 | ),
193 | guestsCanModify: z.boolean().optional().describe(
194 | "Whether attendees can modify the event. Default is false."
195 | ),
196 | guestsCanSeeOtherGuests: z.boolean().optional().describe(
197 | "Whether attendees can see the list of other attendees. Default is true."
198 | ),
199 | anyoneCanAddSelf: z.boolean().optional().describe(
200 | "Whether anyone can add themselves to the event. Default is false."
201 | ),
202 | sendUpdates: z.enum(["all", "externalOnly", "none"]).optional().describe(
203 | "Whether to send notifications about the event creation. 'all' sends to all guests, 'externalOnly' to non-Google Calendar users only, 'none' sends no notifications."
204 | ),
205 | conferenceData: z.object({
206 | createRequest: z.object({
207 | requestId: z.string().describe("Client-generated unique ID for this request to ensure idempotency"),
208 | conferenceSolutionKey: z.object({
209 | type: z.enum(["hangoutsMeet", "eventHangout", "eventNamedHangout", "addOn"]).describe("Conference solution type")
210 | }).describe("Conference solution to create")
211 | }).describe("Request to generate a new conference")
212 | }).optional().describe(
213 | "Conference properties for the event. Use createRequest to add a new conference."
214 | ),
215 | extendedProperties: z.object({
216 | private: z.record(z.string()).optional().describe(
217 | "Properties private to the application. Keys can have max 44 chars, values max 1024 chars."
218 | ),
219 | shared: z.record(z.string()).optional().describe(
220 | "Properties visible to all attendees. Keys can have max 44 chars, values max 1024 chars."
221 | )
222 | }).optional().describe(
223 | "Extended properties for storing application-specific data. Max 300 properties totaling 32KB."
224 | ),
225 | attachments: z.array(z.object({
226 | fileUrl: z.string().describe("URL of the attached file"),
227 | title: z.string().optional().describe("Title of the attachment"),
228 | mimeType: z.string().optional().describe("MIME type of the attachment"),
229 | iconLink: z.string().optional().describe("URL of the icon for the attachment"),
230 | fileId: z.string().optional().describe("ID of the attached file in Google Drive")
231 | })).optional().describe(
232 | "File attachments for the event. Requires calendar to support attachments."
233 | ),
234 | source: z.object({
235 | url: z.string().describe("URL of the source"),
236 | title: z.string().describe("Title of the source")
237 | }).optional().describe(
238 | "Source of the event, such as a web page or email message."
239 | ),
240 | calendarsToCheck: z.array(z.string()).optional().describe(
241 | "List of calendar IDs to check for conflicts (defaults to just the target calendar)"
242 | ),
243 | duplicateSimilarityThreshold: z.number().min(0).max(1).optional().describe(
244 | "Threshold for duplicate detection (0-1, default: 0.7). Events with similarity above this are flagged as potential duplicates"
245 | ),
246 | allowDuplicates: z.boolean().optional().describe(
247 | "If true, allows creation even when exact duplicates are detected (similarity >= 0.95). Default is false which blocks duplicate creation"
248 | )
249 | }),
250 |
251 | 'update-event': z.object({
252 | calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"),
253 | eventId: z.string().describe("ID of the event to update"),
254 | summary: z.string().optional().describe("Updated title of the event"),
255 | description: z.string().optional().describe("Updated description/notes"),
256 | start: z.string()
257 | .refine((val) => {
258 | const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(val);
259 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
260 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
261 | return dateOnly || withTimezone || withoutTimezone;
262 | }, "Must be ISO 8601 format: '2025-01-01T10:00:00' for timed events or '2025-01-01' for all-day events")
263 | .describe("Updated start time: '2025-01-01T10:00:00' for timed events or '2025-01-01' for all-day events. Also accepts Google Calendar API object format: {date: '2025-01-01'} or {dateTime: '2025-01-01T10:00:00', timeZone: 'America/Los_Angeles'}")
264 | .optional(),
265 | end: z.string()
266 | .refine((val) => {
267 | const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(val);
268 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
269 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
270 | return dateOnly || withTimezone || withoutTimezone;
271 | }, "Must be ISO 8601 format: '2025-01-01T11:00:00' for timed events or '2025-01-02' for all-day events")
272 | .describe("Updated end time: '2025-01-01T11:00:00' for timed events or '2025-01-02' for all-day events (exclusive). Also accepts Google Calendar API object format: {date: '2025-01-02'} or {dateTime: '2025-01-01T11:00:00', timeZone: 'America/Los_Angeles'}")
273 | .optional(),
274 | timeZone: z.string().optional().describe("Updated timezone as IANA Time Zone Database name. If not provided, uses the calendar's default timezone."),
275 | location: z.string().optional().describe("Updated location"),
276 | attendees: z.array(z.object({
277 | email: z.string().email().describe("Email address of the attendee")
278 | })).optional().describe("Updated attendee list"),
279 | colorId: z.string().optional().describe("Updated color ID"),
280 | reminders: z.object({
281 | useDefault: z.boolean().describe("Whether to use the default reminders"),
282 | overrides: z.array(z.object({
283 | method: z.enum(["email", "popup"]).default("popup").describe("Reminder method"),
284 | minutes: z.number().describe("Minutes before the event to trigger the reminder")
285 | }).partial({ method: true })).optional().describe("Custom reminders")
286 | }).describe("Reminder settings for the event").optional(),
287 | recurrence: z.array(z.string()).optional().describe("Updated recurrence rules"),
288 | sendUpdates: z.enum(["all", "externalOnly", "none"]).default("all").describe(
289 | "Whether to send update notifications"
290 | ),
291 | modificationScope: z.enum(["thisAndFollowing", "all", "thisEventOnly"]).optional().describe(
292 | "Scope for recurring event modifications"
293 | ),
294 | originalStartTime: z.string()
295 | .refine((val) => {
296 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
297 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
298 | return withTimezone || withoutTimezone;
299 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
300 | .describe("Original start time in the ISO 8601 format '2024-01-01T10:00:00'")
301 | .optional(),
302 | futureStartDate: z.string()
303 | .refine((val) => {
304 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
305 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
306 | return withTimezone || withoutTimezone;
307 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
308 | .describe("Start date for future instances in the ISO 8601 format '2024-01-01T10:00:00'")
309 | .optional(),
310 | checkConflicts: z.boolean().optional().describe(
311 | "Whether to check for conflicts when updating (default: true when changing time)"
312 | ),
313 | calendarsToCheck: z.array(z.string()).optional().describe(
314 | "List of calendar IDs to check for conflicts (defaults to just the target calendar)"
315 | ),
316 | conferenceData: z.object({
317 | createRequest: z.object({
318 | requestId: z.string().describe("Client-generated unique ID for this request to ensure idempotency"),
319 | conferenceSolutionKey: z.object({
320 | type: z.enum(["hangoutsMeet", "eventHangout", "eventNamedHangout", "addOn"]).describe("Conference solution type")
321 | }).describe("Conference solution to create")
322 | }).describe("Request to generate a new conference for this event")
323 | }).optional().describe("Conference properties for the event. Used to add or update Google Meet links."),
324 | transparency: z.enum(["opaque", "transparent"]).optional().describe(
325 | "Whether the event blocks time on the calendar. 'opaque' means busy, 'transparent' means available"
326 | ),
327 | visibility: z.enum(["default", "public", "private", "confidential"]).optional().describe(
328 | "Visibility of the event"
329 | ),
330 | guestsCanInviteOthers: z.boolean().optional().describe(
331 | "Whether attendees other than the organizer can invite others"
332 | ),
333 | guestsCanModify: z.boolean().optional().describe(
334 | "Whether attendees other than the organizer can modify the event"
335 | ),
336 | guestsCanSeeOtherGuests: z.boolean().optional().describe(
337 | "Whether attendees other than the organizer can see who the event's attendees are"
338 | ),
339 | anyoneCanAddSelf: z.boolean().optional().describe(
340 | "Whether anyone can add themselves to the event"
341 | ),
342 | extendedProperties: z.object({
343 | private: z.record(z.string()).optional().describe("Properties that are private to the creator's app"),
344 | shared: z.record(z.string()).optional().describe("Properties that are shared between all apps")
345 | }).partial().optional().describe("Extended properties for the event"),
346 | attachments: z.array(z.object({
347 | fileUrl: z.string().url().describe("URL link to the attachment"),
348 | title: z.string().describe("Title of the attachment"),
349 | mimeType: z.string().optional().describe("MIME type of the attachment"),
350 | iconLink: z.string().optional().describe("URL link to the attachment's icon"),
351 | fileId: z.string().optional().describe("ID of the attached Google Drive file")
352 | })).optional().describe("File attachments for the event")
353 | }).refine(
354 | (data) => {
355 | // Require originalStartTime when modificationScope is 'thisEventOnly'
356 | if (data.modificationScope === 'thisEventOnly' && !data.originalStartTime) {
357 | return false;
358 | }
359 | return true;
360 | },
361 | {
362 | message: "originalStartTime is required when modificationScope is 'thisEventOnly'",
363 | path: ["originalStartTime"]
364 | }
365 | ).refine(
366 | (data) => {
367 | // Require futureStartDate when modificationScope is 'thisAndFollowing'
368 | if (data.modificationScope === 'thisAndFollowing' && !data.futureStartDate) {
369 | return false;
370 | }
371 | return true;
372 | },
373 | {
374 | message: "futureStartDate is required when modificationScope is 'thisAndFollowing'",
375 | path: ["futureStartDate"]
376 | }
377 | ).refine(
378 | (data) => {
379 | // Ensure futureStartDate is in the future when provided
380 | if (data.futureStartDate) {
381 | const futureDate = new Date(data.futureStartDate);
382 | const now = new Date();
383 | return futureDate > now;
384 | }
385 | return true;
386 | },
387 | {
388 | message: "futureStartDate must be in the future",
389 | path: ["futureStartDate"]
390 | }
391 | ),
392 |
393 | 'delete-event': z.object({
394 | calendarId: z.string().describe("ID of the calendar (use 'primary' for the main calendar)"),
395 | eventId: z.string().describe("ID of the event to delete"),
396 | sendUpdates: z.enum(["all", "externalOnly", "none"]).default("all").describe(
397 | "Whether to send cancellation notifications"
398 | )
399 | }),
400 |
401 | 'get-freebusy': z.object({
402 | calendars: z.array(z.object({
403 | id: z.string().describe("ID of the calendar (use 'primary' for the main calendar)")
404 | })).describe(
405 | "List of calendars and/or groups to query for free/busy information"
406 | ),
407 | timeMin: z.string()
408 | .refine((val) => {
409 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
410 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
411 | return withTimezone || withoutTimezone;
412 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
413 | .describe("Start time boundary. Preferred: '2024-01-01T00:00:00' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T00:00:00Z' or '2024-01-01T00:00:00-08:00'."),
414 | timeMax: z.string()
415 | .refine((val) => {
416 | const withTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(val);
417 | const withoutTimezone = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(val);
418 | return withTimezone || withoutTimezone;
419 | }, "Must be ISO 8601 format: '2026-01-01T00:00:00'")
420 | .describe("End time boundary. Preferred: '2024-01-01T23:59:59' (uses timeZone parameter or calendar timezone). Also accepts: '2024-01-01T23:59:59Z' or '2024-01-01T23:59:59-08:00'."),
421 | timeZone: z.string().optional().describe("Timezone for the query"),
422 | groupExpansionMax: z.number().int().max(100).optional().describe(
423 | "Maximum number of calendars to expand per group (max 100)"
424 | ),
425 | calendarExpansionMax: z.number().int().max(50).optional().describe(
426 | "Maximum number of calendars to expand (max 50)"
427 | )
428 | }),
429 |
430 | 'get-current-time': z.object({
431 | timeZone: z.string().optional().describe(
432 | "Optional IANA timezone (e.g., 'America/Los_Angeles', 'Europe/London', 'UTC'). If not provided, uses the primary Google Calendar's default timezone."
433 | )
434 | })
435 | } as const;
436 |
437 | // Generate TypeScript types from schemas
438 | export type ToolInputs = {
439 | [K in keyof typeof ToolSchemas]: z.infer<typeof ToolSchemas[K]>
440 | };
441 |
442 | // Export individual types for convenience
443 | export type ListCalendarsInput = ToolInputs['list-calendars'];
444 | export type ListEventsInput = ToolInputs['list-events'];
445 | export type SearchEventsInput = ToolInputs['search-events'];
446 | export type GetEventInput = ToolInputs['get-event'];
447 | export type ListColorsInput = ToolInputs['list-colors'];
448 | export type CreateEventInput = ToolInputs['create-event'];
449 | export type UpdateEventInput = ToolInputs['update-event'];
450 | export type DeleteEventInput = ToolInputs['delete-event'];
451 | export type GetFreeBusyInput = ToolInputs['get-freebusy'];
452 | export type GetCurrentTimeInput = ToolInputs['get-current-time'];
453 |
454 | interface ToolDefinition {
455 | name: keyof typeof ToolSchemas;
456 | description: string;
457 | schema: z.ZodType<any>;
458 | handler: new () => BaseToolHandler;
459 | handlerFunction?: (args: any) => Promise<any>;
460 | customInputSchema?: any; // Custom schema shape for MCP registration (overrides extractSchemaShape)
461 | }
462 |
463 |
464 | export class ToolRegistry {
465 | private static extractSchemaShape(schema: z.ZodType<any>): any {
466 | const schemaAny = schema as any;
467 |
468 | // Handle ZodEffects (schemas with .refine())
469 | if (schemaAny._def && schemaAny._def.typeName === 'ZodEffects') {
470 | return this.extractSchemaShape(schemaAny._def.schema);
471 | }
472 |
473 | // Handle regular ZodObject
474 | if ('shape' in schemaAny) {
475 | return schemaAny.shape;
476 | }
477 |
478 | // Handle other nested structures
479 | if (schemaAny._def && schemaAny._def.schema) {
480 | return this.extractSchemaShape(schemaAny._def.schema);
481 | }
482 |
483 | // Fallback to the original approach
484 | return schemaAny._def?.schema?.shape || schemaAny.shape;
485 | }
486 |
487 | private static tools: ToolDefinition[] = [
488 | {
489 | name: "list-calendars",
490 | description: "List all available calendars",
491 | schema: ToolSchemas['list-calendars'],
492 | handler: ListCalendarsHandler
493 | },
494 | {
495 | name: "list-events",
496 | description: "List events from one or more calendars. Supports both calendar IDs and calendar names.",
497 | schema: ToolSchemas['list-events'],
498 | handler: ListEventsHandler,
499 | handlerFunction: async (args: ListEventsInput & { calendarId: string | string[] }) => {
500 | let processedCalendarId: string | string[] = args.calendarId;
501 |
502 | // If it's already an array (native array format), keep as-is (already validated by schema)
503 | if (Array.isArray(args.calendarId)) {
504 | processedCalendarId = args.calendarId;
505 | }
506 | // Handle JSON string format (double or single-quoted)
507 | else if (typeof args.calendarId === 'string' && args.calendarId.trim().startsWith('[') && args.calendarId.trim().endsWith(']')) {
508 | try {
509 | let jsonString = args.calendarId.trim();
510 |
511 | // Normalize single-quoted JSON-like strings to valid JSON (Python/shell style)
512 | // Only replace single quotes that are string delimiters (after '[', ',', or before ']', ',')
513 | // This avoids breaking calendar IDs with apostrophes like "John's Calendar"
514 | if (jsonString.includes("'")) {
515 | jsonString = jsonString
516 | .replace(/\[\s*'/g, '["') // [' -> ["
517 | .replace(/'\s*,\s*'/g, '", "') // ', ' -> ", "
518 | .replace(/'\s*\]/g, '"]'); // '] -> "]
519 | }
520 |
521 | const parsed = JSON.parse(jsonString);
522 |
523 | // Validate parsed result
524 | if (!Array.isArray(parsed)) {
525 | throw new Error('JSON string must contain an array');
526 | }
527 | if (!parsed.every(id => typeof id === 'string' && id.length > 0)) {
528 | throw new Error('Array must contain only non-empty strings');
529 | }
530 | if (parsed.length === 0) {
531 | throw new Error("At least one calendar ID is required");
532 | }
533 | if (parsed.length > 50) {
534 | throw new Error("Maximum 50 calendars allowed");
535 | }
536 | if (new Set(parsed).size !== parsed.length) {
537 | throw new Error("Duplicate calendar IDs are not allowed");
538 | }
539 |
540 | processedCalendarId = parsed;
541 | } catch (error) {
542 | throw new Error(
543 | `Invalid JSON format for calendarId: ${error instanceof Error ? error.message : 'Unknown parsing error'}`
544 | );
545 | }
546 | }
547 | // Otherwise it's a single string calendar ID - keep as-is
548 |
549 | return {
550 | calendarId: processedCalendarId,
551 | timeMin: args.timeMin,
552 | timeMax: args.timeMax,
553 | timeZone: args.timeZone,
554 | fields: args.fields,
555 | privateExtendedProperty: args.privateExtendedProperty,
556 | sharedExtendedProperty: args.sharedExtendedProperty
557 | };
558 | }
559 | },
560 | {
561 | name: "search-events",
562 | description: "Search for events in a calendar by text query.",
563 | schema: ToolSchemas['search-events'],
564 | handler: SearchEventsHandler
565 | },
566 | {
567 | name: "get-event",
568 | description: "Get details of a specific event by ID.",
569 | schema: ToolSchemas['get-event'],
570 | handler: GetEventHandler
571 | },
572 | {
573 | name: "list-colors",
574 | description: "List available color IDs and their meanings for calendar events",
575 | schema: ToolSchemas['list-colors'],
576 | handler: ListColorsHandler
577 | },
578 | {
579 | name: "create-event",
580 | description: "Create a new calendar event.",
581 | schema: ToolSchemas['create-event'],
582 | handler: CreateEventHandler
583 | },
584 | {
585 | name: "update-event",
586 | description: "Update an existing calendar event with recurring event modification scope support.",
587 | schema: ToolSchemas['update-event'],
588 | handler: UpdateEventHandler
589 | },
590 | {
591 | name: "delete-event",
592 | description: "Delete a calendar event.",
593 | schema: ToolSchemas['delete-event'],
594 | handler: DeleteEventHandler
595 | },
596 | {
597 | name: "get-freebusy",
598 | description: "Query free/busy information for calendars. Note: Time range is limited to a maximum of 3 months between timeMin and timeMax.",
599 | schema: ToolSchemas['get-freebusy'],
600 | handler: FreeBusyEventHandler
601 | },
602 | {
603 | name: "get-current-time",
604 | description: "Get current time in the primary Google Calendar's timezone (or a requested timezone).",
605 | schema: ToolSchemas['get-current-time'],
606 | handler: GetCurrentTimeHandler
607 | }
608 | ];
609 |
610 | static getToolsWithSchemas() {
611 | return this.tools.map(tool => {
612 | const jsonSchema = tool.customInputSchema
613 | ? zodToJsonSchema(z.object(tool.customInputSchema))
614 | : zodToJsonSchema(tool.schema);
615 | return {
616 | name: tool.name,
617 | description: tool.description,
618 | inputSchema: jsonSchema
619 | };
620 | });
621 | }
622 |
623 | /**
624 | * Normalizes datetime fields from object format to string format
625 | * Converts { date: "2025-01-01" } or { dateTime: "...", timeZone: "..." } to simple strings
626 | * This allows accepting both Google Calendar API format and our simplified format
627 | */
628 | private static normalizeDateTimeFields(toolName: string, args: any): any {
629 | // Only normalize for tools that have datetime fields
630 | const toolsWithDateTime = ['create-event', 'update-event'];
631 | if (!toolsWithDateTime.includes(toolName)) {
632 | return args;
633 | }
634 |
635 | const normalized = { ...args };
636 | const dateTimeFields = ['start', 'end', 'originalStartTime', 'futureStartDate'];
637 |
638 | for (const field of dateTimeFields) {
639 | if (normalized[field] && typeof normalized[field] === 'object') {
640 | const obj = normalized[field];
641 | // Convert object format to string format
642 | if (obj.date) {
643 | normalized[field] = obj.date;
644 | } else if (obj.dateTime) {
645 | normalized[field] = obj.dateTime;
646 | }
647 | }
648 | }
649 |
650 | return normalized;
651 | }
652 |
653 | static async registerAll(
654 | server: McpServer,
655 | executeWithHandler: (
656 | handler: any,
657 | args: any
658 | ) => Promise<{ content: Array<{ type: "text"; text: string }> }>
659 | ) {
660 | for (const tool of this.tools) {
661 | // Use the existing registerTool method which handles schema conversion properly
662 | server.registerTool(
663 | tool.name,
664 | {
665 | description: tool.description,
666 | inputSchema: tool.customInputSchema || this.extractSchemaShape(tool.schema)
667 | },
668 | async (args: any) => {
669 | // Preprocess: Normalize datetime fields (convert object format to string format)
670 | // This allows accepting both formats while keeping schemas simple
671 | const normalizedArgs = this.normalizeDateTimeFields(tool.name, args);
672 |
673 | // Validate input using our Zod schema
674 | const validatedArgs = tool.schema.parse(normalizedArgs);
675 |
676 | // Apply any custom handler function preprocessing
677 | const processedArgs = tool.handlerFunction ? await tool.handlerFunction(validatedArgs) : validatedArgs;
678 |
679 | // Create handler instance and execute
680 | const handler = new tool.handler();
681 | return executeWithHandler(handler, processedArgs);
682 | }
683 | );
684 | }
685 | }
686 | }
687 |
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/UpdateEventHandler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { UpdateEventHandler } from '../../../handlers/core/UpdateEventHandler.js';
3 | import { OAuth2Client } from 'google-auth-library';
4 | import type { UpdateEventInput } from '../../../tools/registry.js';
5 | import type { RecurringEventHelpers } from '../../../handlers/core/RecurringEventHelpers.js';
6 |
7 | // Mock the googleapis module
8 | vi.mock('googleapis', () => ({
9 | google: {
10 | calendar: vi.fn(() => ({
11 | events: {
12 | patch: vi.fn(),
13 | get: vi.fn()
14 | },
15 | calendars: {
16 | get: vi.fn()
17 | }
18 | }))
19 | },
20 | calendar_v3: {}
21 | }));
22 |
23 | // Import createTimeObject for proper datetime handling in mocks
24 | import { createTimeObject } from '../../../handlers/utils/datetime.js';
25 |
26 | // Mock RecurringEventHelpers
27 | vi.mock('../../../handlers/core/RecurringEventHelpers.js', () => ({
28 | RecurringEventHelpers: vi.fn().mockImplementation((calendar) => ({
29 | detectEventType: vi.fn().mockResolvedValue('single'),
30 | getCalendar: vi.fn(() => calendar),
31 | buildUpdateRequestBody: vi.fn((args, defaultTimeZone) => {
32 | const body: any = {};
33 | if (args.summary !== undefined && args.summary !== null) body.summary = args.summary;
34 | if (args.description !== undefined && args.description !== null) body.description = args.description;
35 | if (args.location !== undefined && args.location !== null) body.location = args.location;
36 | const tz = args.timeZone || defaultTimeZone;
37 |
38 | // Use createTimeObject to handle both timed and all-day events
39 | if (args.start !== undefined && args.start !== null) {
40 | const timeObj = createTimeObject(args.start, tz);
41 | // When converting formats, explicitly nullify the opposite field
42 | if (timeObj.date !== undefined) {
43 | body.start = { date: timeObj.date, dateTime: null };
44 | } else {
45 | body.start = { dateTime: timeObj.dateTime, timeZone: timeObj.timeZone, date: null };
46 | }
47 | }
48 | if (args.end !== undefined && args.end !== null) {
49 | const timeObj = createTimeObject(args.end, tz);
50 | // When converting formats, explicitly nullify the opposite field
51 | if (timeObj.date !== undefined) {
52 | body.end = { date: timeObj.date, dateTime: null };
53 | } else {
54 | body.end = { dateTime: timeObj.dateTime, timeZone: timeObj.timeZone, date: null };
55 | }
56 | }
57 |
58 | if (args.attendees !== undefined && args.attendees !== null) body.attendees = args.attendees;
59 | if (args.colorId !== undefined && args.colorId !== null) body.colorId = args.colorId;
60 | if (args.reminders !== undefined && args.reminders !== null) body.reminders = args.reminders;
61 | if (args.conferenceData !== undefined && args.conferenceData !== null) body.conferenceData = args.conferenceData;
62 | if (args.transparency !== undefined && args.transparency !== null) body.transparency = args.transparency;
63 | if (args.visibility !== undefined && args.visibility !== null) body.visibility = args.visibility;
64 | if (args.guestsCanInviteOthers !== undefined) body.guestsCanInviteOthers = args.guestsCanInviteOthers;
65 | if (args.guestsCanModify !== undefined) body.guestsCanModify = args.guestsCanModify;
66 | if (args.guestsCanSeeOtherGuests !== undefined) body.guestsCanSeeOtherGuests = args.guestsCanSeeOtherGuests;
67 | if (args.anyoneCanAddSelf !== undefined) body.anyoneCanAddSelf = args.anyoneCanAddSelf;
68 | if (args.extendedProperties !== undefined && args.extendedProperties !== null) body.extendedProperties = args.extendedProperties;
69 | if (args.attachments !== undefined && args.attachments !== null) body.attachments = args.attachments;
70 | return body;
71 | })
72 | })),
73 | RecurringEventError: class extends Error {
74 | code: string;
75 | constructor(message: string, code: string) {
76 | super(message);
77 | this.code = code;
78 | }
79 | },
80 | RECURRING_EVENT_ERRORS: {
81 | NON_RECURRING_SCOPE: 'NON_RECURRING_SCOPE'
82 | }
83 | }));
84 |
85 | describe('UpdateEventHandler', () => {
86 | let handler: UpdateEventHandler;
87 | let mockOAuth2Client: OAuth2Client;
88 | let mockCalendar: any;
89 |
90 | beforeEach(() => {
91 | handler = new UpdateEventHandler();
92 | mockOAuth2Client = new OAuth2Client();
93 |
94 | // Setup mock calendar
95 | mockCalendar = {
96 | events: {
97 | patch: vi.fn(),
98 | get: vi.fn(),
99 | insert: vi.fn()
100 | },
101 | calendars: {
102 | get: vi.fn()
103 | }
104 | };
105 |
106 | // Mock the getCalendar method
107 | vi.spyOn(handler as any, 'getCalendar').mockReturnValue(mockCalendar);
108 |
109 | // Mock getCalendarTimezone
110 | vi.spyOn(handler as any, 'getCalendarTimezone').mockResolvedValue('America/Los_Angeles');
111 | });
112 |
113 | describe('Basic Event Updates', () => {
114 | it('should update event summary', async () => {
115 | const mockUpdatedEvent = {
116 | id: 'event123',
117 | summary: 'Updated Meeting',
118 | start: { dateTime: '2025-01-15T10:00:00Z' },
119 | end: { dateTime: '2025-01-15T11:00:00Z' },
120 | htmlLink: 'https://calendar.google.com/event?eid=abc123'
121 | };
122 |
123 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
124 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
125 |
126 | const args = {
127 | calendarId: 'primary',
128 | eventId: 'event123',
129 | summary: 'Updated Meeting'
130 | };
131 |
132 | const result = await handler.runTool(args, mockOAuth2Client);
133 |
134 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
135 | calendarId: 'primary',
136 | eventId: 'event123',
137 | requestBody: expect.objectContaining({
138 | summary: 'Updated Meeting'
139 | })
140 | });
141 |
142 | expect(result.content[0].type).toBe('text');
143 | const response = JSON.parse((result.content[0] as any).text);
144 | expect(response.event).toBeDefined();
145 | expect(response.event.summary).toBe('Updated Meeting');
146 | });
147 |
148 | it('should update event description and location', async () => {
149 | const mockUpdatedEvent = {
150 | id: 'event123',
151 | summary: 'Meeting',
152 | description: 'New description',
153 | location: 'Conference Room B',
154 | start: { dateTime: '2025-01-15T10:00:00Z' },
155 | end: { dateTime: '2025-01-15T11:00:00Z' }
156 | };
157 |
158 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
159 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
160 |
161 | const args = {
162 | calendarId: 'primary',
163 | eventId: 'event123',
164 | description: 'New description',
165 | location: 'Conference Room B'
166 | };
167 |
168 | const result = await handler.runTool(args, mockOAuth2Client);
169 |
170 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
171 | calendarId: 'primary',
172 | eventId: 'event123',
173 | requestBody: expect.objectContaining({
174 | description: 'New description',
175 | location: 'Conference Room B'
176 | })
177 | });
178 |
179 | const response = JSON.parse((result.content[0] as any).text);
180 | expect(response.event).toBeDefined();
181 | expect(response.event.description).toBe('New description');
182 | expect(response.event.location).toBe('Conference Room B');
183 | });
184 |
185 | it('should update event times', async () => {
186 | const mockUpdatedEvent = {
187 | id: 'event123',
188 | summary: 'Meeting',
189 | start: { dateTime: '2025-01-16T14:00:00Z' },
190 | end: { dateTime: '2025-01-16T15:00:00Z' }
191 | };
192 |
193 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
194 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
195 |
196 | const args = {
197 | calendarId: 'primary',
198 | eventId: 'event123',
199 | start: '2025-01-16T14:00:00',
200 | end: '2025-01-16T15:00:00',
201 | timeZone: 'America/Los_Angeles'
202 | };
203 |
204 | const result = await handler.runTool(args, mockOAuth2Client);
205 |
206 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
207 | calendarId: 'primary',
208 | eventId: 'event123',
209 | requestBody: expect.objectContaining({
210 | start: { dateTime: '2025-01-16T14:00:00', timeZone: 'America/Los_Angeles', date: null },
211 | end: { dateTime: '2025-01-16T15:00:00', timeZone: 'America/Los_Angeles', date: null }
212 | })
213 | });
214 |
215 | const response = JSON.parse((result.content[0] as any).text);
216 | expect(response.event).toBeDefined();
217 | });
218 |
219 | it('should update attendees', async () => {
220 | const mockUpdatedEvent = {
221 | id: 'event123',
222 | summary: 'Meeting',
223 | attendees: [
224 | { email: '[email protected]' },
225 | { email: '[email protected]' }
226 | ]
227 | };
228 |
229 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
230 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
231 |
232 | const args = {
233 | calendarId: 'primary',
234 | eventId: 'event123',
235 | attendees: [
236 | { email: '[email protected]' },
237 | { email: '[email protected]' }
238 | ],
239 | sendUpdates: 'all' as const
240 | };
241 |
242 | const result = await handler.runTool(args, mockOAuth2Client);
243 |
244 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
245 | calendarId: 'primary',
246 | eventId: 'event123',
247 | requestBody: expect.objectContaining({
248 | attendees: [
249 | { email: '[email protected]' },
250 | { email: '[email protected]' }
251 | ]
252 | })
253 | });
254 |
255 | const response = JSON.parse((result.content[0] as any).text);
256 | expect(response.event).toBeDefined();
257 | });
258 |
259 | it('should update reminders', async () => {
260 | const mockUpdatedEvent = {
261 | id: 'event123',
262 | summary: 'Meeting',
263 | reminders: {
264 | useDefault: false,
265 | overrides: [
266 | { method: 'email', minutes: 30 },
267 | { method: 'popup', minutes: 10 }
268 | ]
269 | }
270 | };
271 |
272 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
273 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
274 |
275 | const args = {
276 | calendarId: 'primary',
277 | eventId: 'event123',
278 | reminders: {
279 | useDefault: false,
280 | overrides: [
281 | { method: 'email' as const, minutes: 30 },
282 | { method: 'popup' as const, minutes: 10 }
283 | ]
284 | }
285 | };
286 |
287 | const result = await handler.runTool(args, mockOAuth2Client);
288 |
289 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
290 | calendarId: 'primary',
291 | eventId: 'event123',
292 | requestBody: expect.objectContaining({
293 | reminders: {
294 | useDefault: false,
295 | overrides: [
296 | { method: 'email', minutes: 30 },
297 | { method: 'popup', minutes: 10 }
298 | ]
299 | }
300 | })
301 | });
302 |
303 | const response = JSON.parse((result.content[0] as any).text);
304 | expect(response.event).toBeDefined();
305 | });
306 |
307 | it('should update guest permissions', async () => {
308 | const mockUpdatedEvent = {
309 | id: 'event123',
310 | summary: 'Team Meeting',
311 | guestsCanInviteOthers: false,
312 | guestsCanModify: true,
313 | guestsCanSeeOtherGuests: false
314 | };
315 |
316 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
317 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
318 |
319 | const args = {
320 | calendarId: 'primary',
321 | eventId: 'event123',
322 | guestsCanInviteOthers: false,
323 | guestsCanModify: true,
324 | guestsCanSeeOtherGuests: false,
325 | anyoneCanAddSelf: true
326 | };
327 |
328 | const result = await handler.runTool(args, mockOAuth2Client);
329 |
330 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
331 | calendarId: 'primary',
332 | eventId: 'event123',
333 | requestBody: expect.objectContaining({
334 | guestsCanInviteOthers: false,
335 | guestsCanModify: true,
336 | guestsCanSeeOtherGuests: false,
337 | anyoneCanAddSelf: true
338 | })
339 | });
340 |
341 | const response = JSON.parse(result.content[0].text as string);
342 | expect(response).toHaveProperty('event');
343 | expect(response.event.id).toBe('event123');
344 | expect(response.event.guestsCanInviteOthers).toBe(false);
345 | expect(response.event.guestsCanModify).toBe(true);
346 | expect(response.event.guestsCanSeeOtherGuests).toBe(false);
347 | });
348 |
349 | it('should update event with conference data', async () => {
350 | const mockUpdatedEvent = {
351 | id: 'event123',
352 | summary: 'Video Meeting',
353 | conferenceData: {
354 | entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }]
355 | }
356 | };
357 |
358 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
359 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
360 |
361 | const args = {
362 | calendarId: 'primary',
363 | eventId: 'event123',
364 | summary: 'Video Meeting',
365 | conferenceData: {
366 | createRequest: {
367 | requestId: 'unique-request-456',
368 | conferenceSolutionKey: {
369 | type: 'hangoutsMeet' as const
370 | }
371 | }
372 | }
373 | };
374 |
375 | const result = await handler.runTool(args, mockOAuth2Client);
376 |
377 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
378 | calendarId: 'primary',
379 | eventId: 'event123',
380 | requestBody: expect.objectContaining({
381 | summary: 'Video Meeting',
382 | conferenceData: {
383 | createRequest: {
384 | requestId: 'unique-request-456',
385 | conferenceSolutionKey: {
386 | type: 'hangoutsMeet'
387 | }
388 | }
389 | }
390 | }),
391 | conferenceDataVersion: 1
392 | });
393 |
394 | const response = JSON.parse(result.content[0].text as string);
395 | expect(response).toHaveProperty('event');
396 | expect(response.event.id).toBe('event123');
397 | expect(response.event.summary).toBe('Video Meeting');
398 | expect(response.event.conferenceData).toBeDefined();
399 | expect(response.event.conferenceData.entryPoints).toBeDefined();
400 | });
401 |
402 | it('should update color ID', async () => {
403 | const mockUpdatedEvent = {
404 | id: 'event123',
405 | summary: 'Meeting',
406 | colorId: '7'
407 | };
408 |
409 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
410 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
411 |
412 | const args = {
413 | calendarId: 'primary',
414 | eventId: 'event123',
415 | colorId: '7'
416 | };
417 |
418 | const result = await handler.runTool(args, mockOAuth2Client);
419 |
420 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
421 | calendarId: 'primary',
422 | eventId: 'event123',
423 | requestBody: expect.objectContaining({
424 | colorId: '7'
425 | })
426 | });
427 |
428 | const response = JSON.parse((result.content[0] as any).text);
429 | expect(response.event).toBeDefined();
430 | });
431 |
432 | it('should update multiple fields at once', async () => {
433 | const mockUpdatedEvent = {
434 | id: 'event123',
435 | summary: 'Updated Meeting',
436 | description: 'Updated description',
437 | location: 'New Location',
438 | start: { dateTime: '2025-01-16T14:00:00Z' },
439 | end: { dateTime: '2025-01-16T15:00:00Z' },
440 | attendees: [{ email: '[email protected]' }],
441 | colorId: '5',
442 | reminders: { useDefault: true }
443 | };
444 |
445 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
446 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
447 |
448 | const args = {
449 | calendarId: 'primary',
450 | eventId: 'event123',
451 | summary: 'Updated Meeting',
452 | description: 'Updated description',
453 | location: 'New Location',
454 | start: '2025-01-16T14:00:00',
455 | end: '2025-01-16T15:00:00',
456 | attendees: [{ email: '[email protected]' }],
457 | colorId: '5',
458 | reminders: { useDefault: true },
459 | sendUpdates: 'externalOnly' as const
460 | };
461 |
462 | const result = await handler.runTool(args, mockOAuth2Client);
463 |
464 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
465 | calendarId: 'primary',
466 | eventId: 'event123',
467 | requestBody: expect.objectContaining({
468 | summary: 'Updated Meeting',
469 | description: 'Updated description',
470 | location: 'New Location',
471 | colorId: '5'
472 | })
473 | });
474 |
475 | const response = JSON.parse((result.content[0] as any).text);
476 | expect(response.event).toBeDefined();
477 | });
478 | });
479 |
480 | describe('Attachments and conference data handling', () => {
481 | it('should set supportsAttachments when clearing attachments', async () => {
482 | const mockUpdatedEvent = {
483 | id: 'event123',
484 | summary: 'Meeting'
485 | };
486 |
487 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
488 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
489 |
490 | const args = {
491 | calendarId: 'primary',
492 | eventId: 'event123',
493 | attachments: []
494 | };
495 |
496 | await handler.runTool(args, mockOAuth2Client);
497 |
498 | const patchCall = mockCalendar.events.patch.mock.calls[0][0];
499 | expect(patchCall.requestBody.attachments).toEqual([]);
500 | expect(patchCall.supportsAttachments).toBe(true);
501 | });
502 |
503 | it('should set supportsAttachments when duplicating attachments for future instances', async () => {
504 | const originalEvent = {
505 | id: 'recurring123',
506 | recurrence: ['RRULE:FREQ=WEEKLY'],
507 | start: { dateTime: '2025-01-01T10:00:00Z' },
508 | end: { dateTime: '2025-01-01T11:00:00Z' }
509 | };
510 |
511 | mockCalendar.events.get.mockResolvedValue({ data: originalEvent });
512 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
513 | mockCalendar.events.insert.mockResolvedValue({ data: { id: 'newEvent' } });
514 |
515 | const helpersStub = {
516 | getCalendar: () => mockCalendar,
517 | buildUpdateRequestBody: vi.fn().mockReturnValue({}),
518 | cleanEventForDuplication: vi.fn().mockReturnValue({
519 | attachments: [{ fileId: 'file1', fileUrl: 'https://drive.google.com/file1' }],
520 | recurrence: originalEvent.recurrence
521 | }),
522 | calculateEndTime: vi.fn().mockReturnValue('2025-02-01T11:00:00Z'),
523 | calculateUntilDate: vi.fn().mockReturnValue('20250131T100000Z'),
524 | updateRecurrenceWithUntil: vi.fn().mockReturnValue(['RRULE:FREQ=WEEKLY;UNTIL=20250131T100000Z'])
525 | } as unknown as RecurringEventHelpers;
526 |
527 | const args = {
528 | calendarId: 'primary',
529 | eventId: 'recurring123',
530 | futureStartDate: '2025-02-01T10:00:00-08:00',
531 | timeZone: 'America/Los_Angeles'
532 | } as UpdateEventInput;
533 |
534 | await (handler as any).updateFutureInstances(helpersStub, args, 'America/Los_Angeles');
535 |
536 | const insertCall = mockCalendar.events.insert.mock.calls[0][0];
537 | expect(insertCall.supportsAttachments).toBe(true);
538 | expect(insertCall.requestBody.attachments).toEqual([
539 | { fileId: 'file1', fileUrl: 'https://drive.google.com/file1' }
540 | ]);
541 | });
542 |
543 | it('should set conferenceDataVersion when duplicating conference data for future instances', async () => {
544 | const originalEvent = {
545 | id: 'recurring123',
546 | recurrence: ['RRULE:FREQ=WEEKLY'],
547 | start: { dateTime: '2025-01-01T10:00:00Z' },
548 | end: { dateTime: '2025-01-01T11:00:00Z' },
549 | conferenceData: {
550 | entryPoints: [{ entryPointType: 'video', uri: 'https://meet.google.com/abc-defg-hij' }],
551 | conferenceId: 'abc-defg-hij'
552 | }
553 | };
554 |
555 | mockCalendar.events.get.mockResolvedValue({ data: originalEvent });
556 | mockCalendar.events.patch.mockResolvedValue({ data: {} });
557 | mockCalendar.events.insert.mockResolvedValue({ data: { id: 'newEvent' } });
558 |
559 | const helpersStub = {
560 | getCalendar: () => mockCalendar,
561 | buildUpdateRequestBody: vi.fn().mockReturnValue({}),
562 | cleanEventForDuplication: vi.fn().mockReturnValue({
563 | conferenceData: originalEvent.conferenceData,
564 | recurrence: originalEvent.recurrence
565 | }),
566 | calculateEndTime: vi.fn().mockReturnValue('2025-02-01T11:00:00Z'),
567 | calculateUntilDate: vi.fn().mockReturnValue('20250131T100000Z'),
568 | updateRecurrenceWithUntil: vi.fn().mockReturnValue(['RRULE:FREQ=WEEKLY;UNTIL=20250131T100000Z'])
569 | } as unknown as RecurringEventHelpers;
570 |
571 | const args = {
572 | calendarId: 'primary',
573 | eventId: 'recurring123',
574 | futureStartDate: '2025-02-01T10:00:00-08:00',
575 | timeZone: 'America/Los_Angeles'
576 | } as UpdateEventInput;
577 |
578 | await (handler as any).updateFutureInstances(helpersStub, args, 'America/Los_Angeles');
579 |
580 | const insertCall = mockCalendar.events.insert.mock.calls[0][0];
581 | expect(insertCall.conferenceDataVersion).toBe(1);
582 | expect(insertCall.requestBody.conferenceData).toEqual(originalEvent.conferenceData);
583 | });
584 | });
585 |
586 | describe('Send Updates Options', () => {
587 | it('should send updates to all when specified', async () => {
588 | const mockUpdatedEvent = {
589 | id: 'event123',
590 | summary: 'Updated Meeting'
591 | };
592 |
593 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
594 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
595 |
596 | const args = {
597 | calendarId: 'primary',
598 | eventId: 'event123',
599 | summary: 'Updated Meeting',
600 | sendUpdates: 'all' as const
601 | };
602 |
603 | await handler.runTool(args, mockOAuth2Client);
604 |
605 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
606 | calendarId: 'primary',
607 | eventId: 'event123',
608 | requestBody: expect.objectContaining({
609 | summary: 'Updated Meeting'
610 | })
611 | });
612 | });
613 |
614 | it('should send updates to external users only when specified', async () => {
615 | const mockUpdatedEvent = {
616 | id: 'event123',
617 | summary: 'Updated Meeting'
618 | };
619 |
620 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
621 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
622 |
623 | const args = {
624 | calendarId: 'primary',
625 | eventId: 'event123',
626 | summary: 'Updated Meeting',
627 | sendUpdates: 'externalOnly' as const
628 | };
629 |
630 | await handler.runTool(args, mockOAuth2Client);
631 |
632 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
633 | calendarId: 'primary',
634 | eventId: 'event123',
635 | requestBody: expect.objectContaining({
636 | summary: 'Updated Meeting'
637 | })
638 | });
639 | });
640 |
641 | it('should not send updates when none specified', async () => {
642 | const mockUpdatedEvent = {
643 | id: 'event123',
644 | summary: 'Updated Meeting'
645 | };
646 |
647 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
648 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
649 |
650 | const args = {
651 | calendarId: 'primary',
652 | eventId: 'event123',
653 | summary: 'Updated Meeting',
654 | sendUpdates: 'none' as const
655 | };
656 |
657 | await handler.runTool(args, mockOAuth2Client);
658 |
659 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
660 | calendarId: 'primary',
661 | eventId: 'event123',
662 | requestBody: expect.objectContaining({
663 | summary: 'Updated Meeting'
664 | })
665 | });
666 | });
667 | });
668 |
669 | describe('Error Handling', () => {
670 | it('should handle event not found error', async () => {
671 | const notFoundError = new Error('Not Found');
672 | (notFoundError as any).code = 404;
673 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
674 | mockCalendar.events.patch.mockRejectedValue(notFoundError);
675 |
676 | const args = {
677 | calendarId: 'primary',
678 | eventId: 'nonexistent',
679 | summary: 'Updated Meeting'
680 | };
681 |
682 | // The actual error will be "Not Found" since handleGoogleApiError is not being called
683 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow('Not Found');
684 | });
685 |
686 | it('should handle permission denied error', async () => {
687 | const permissionError = new Error('Forbidden');
688 | (permissionError as any).code = 403;
689 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
690 | mockCalendar.events.patch.mockRejectedValue(permissionError);
691 |
692 | const args = {
693 | calendarId: 'primary',
694 | eventId: 'event123',
695 | summary: 'Updated Meeting'
696 | };
697 |
698 | // Don't mock handleGoogleApiError - let the actual error pass through
699 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow('Forbidden');
700 | });
701 |
702 | it('should reject modification scope on non-recurring events', async () => {
703 | // Mock detectEventType to return 'single' for non-recurring event
704 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
705 |
706 | const args = {
707 | calendarId: 'primary',
708 | eventId: 'event123',
709 | summary: 'Updated Meeting',
710 | modificationScope: 'thisEventOnly' as const
711 | };
712 |
713 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
714 | 'Scope other than "all" only applies to recurring events'
715 | );
716 | });
717 |
718 | it('should handle API errors with response status', async () => {
719 | const apiError = new Error('Bad Request');
720 | (apiError as any).response = { status: 400 };
721 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
722 | mockCalendar.events.patch.mockRejectedValue(apiError);
723 |
724 | const args = {
725 | calendarId: 'primary',
726 | eventId: 'event123',
727 | summary: 'Updated Meeting'
728 | };
729 |
730 | // Mock handleGoogleApiError
731 | vi.spyOn(handler as any, 'handleGoogleApiError').mockImplementation(() => {
732 | throw new Error('Bad Request');
733 | });
734 |
735 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow('Bad Request');
736 | });
737 |
738 | it('should handle missing response data', async () => {
739 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
740 | mockCalendar.events.patch.mockResolvedValue({ data: null });
741 |
742 | const args = {
743 | calendarId: 'primary',
744 | eventId: 'event123',
745 | summary: 'Updated Meeting'
746 | };
747 |
748 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
749 | 'Failed to update event'
750 | );
751 | });
752 | });
753 |
754 | describe('Timezone Handling', () => {
755 | it('should use calendar default timezone when not specified', async () => {
756 | const mockUpdatedEvent = {
757 | id: 'event123',
758 | summary: 'Meeting',
759 | start: { dateTime: '2025-01-16T14:00:00-08:00' },
760 | end: { dateTime: '2025-01-16T15:00:00-08:00' }
761 | };
762 |
763 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
764 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
765 |
766 | const args = {
767 | calendarId: 'primary',
768 | eventId: 'event123',
769 | start: '2025-01-16T14:00:00',
770 | end: '2025-01-16T15:00:00'
771 | // No timeZone specified
772 | };
773 |
774 | await handler.runTool(args, mockOAuth2Client);
775 |
776 | // Should use the mocked default timezone 'America/Los_Angeles'
777 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
778 | calendarId: 'primary',
779 | eventId: 'event123',
780 | requestBody: expect.objectContaining({
781 | start: { dateTime: '2025-01-16T14:00:00', timeZone: 'America/Los_Angeles', date: null },
782 | end: { dateTime: '2025-01-16T15:00:00', timeZone: 'America/Los_Angeles', date: null }
783 | })
784 | });
785 | });
786 |
787 | it('should override calendar timezone when specified', async () => {
788 | const mockUpdatedEvent = {
789 | id: 'event123',
790 | summary: 'Meeting',
791 | start: { dateTime: '2025-01-16T14:00:00+00:00' },
792 | end: { dateTime: '2025-01-16T15:00:00+00:00' }
793 | };
794 |
795 | mockCalendar.events.get.mockResolvedValue({ data: { recurrence: null } });
796 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedEvent });
797 |
798 | const args = {
799 | calendarId: 'primary',
800 | eventId: 'event123',
801 | start: '2025-01-16T14:00:00',
802 | end: '2025-01-16T15:00:00',
803 | timeZone: 'UTC'
804 | };
805 |
806 | await handler.runTool(args, mockOAuth2Client);
807 |
808 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
809 | calendarId: 'primary',
810 | eventId: 'event123',
811 | requestBody: expect.objectContaining({
812 | start: { dateTime: '2025-01-16T14:00:00', timeZone: 'UTC', date: null },
813 | end: { dateTime: '2025-01-16T15:00:00', timeZone: 'UTC', date: null }
814 | })
815 | });
816 | });
817 | });
818 |
819 | describe('All-day Event Conversion (Issue #118)', () => {
820 | it('should convert timed event to all-day event', async () => {
821 | const existingTimedEvent = {
822 | id: 'event123',
823 | summary: 'Timed Meeting',
824 | start: { dateTime: '2025-10-18T10:00:00-07:00' },
825 | end: { dateTime: '2025-10-18T11:00:00-07:00' }
826 | };
827 |
828 | const mockUpdatedAllDayEvent = {
829 | id: 'event123',
830 | summary: 'Timed Meeting',
831 | start: { date: '2025-10-18' },
832 | end: { date: '2025-10-19' }
833 | };
834 |
835 | mockCalendar.events.get.mockResolvedValue({ data: existingTimedEvent });
836 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedAllDayEvent });
837 |
838 | const args = {
839 | calendarId: 'primary',
840 | eventId: 'event123',
841 | start: '2025-10-18',
842 | end: '2025-10-19'
843 | };
844 |
845 | const result = await handler.runTool(args, mockOAuth2Client);
846 |
847 | // Verify patch was called with correct all-day format
848 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
849 | calendarId: 'primary',
850 | eventId: 'event123',
851 | requestBody: expect.objectContaining({
852 | start: { date: '2025-10-18', dateTime: null },
853 | end: { date: '2025-10-19', dateTime: null }
854 | })
855 | });
856 |
857 | const response = JSON.parse((result.content[0] as any).text);
858 | expect(response.event).toBeDefined();
859 | expect(response.event.start.date).toBe('2025-10-18');
860 | expect(response.event.end.date).toBe('2025-10-19');
861 | });
862 |
863 | it('should convert all-day event to timed event', async () => {
864 | const existingAllDayEvent = {
865 | id: 'event456',
866 | summary: 'All Day Event',
867 | start: { date: '2025-10-18' },
868 | end: { date: '2025-10-19' }
869 | };
870 |
871 | const mockUpdatedTimedEvent = {
872 | id: 'event456',
873 | summary: 'All Day Event',
874 | start: { dateTime: '2025-10-18T10:00:00-07:00', timeZone: 'America/Los_Angeles' },
875 | end: { dateTime: '2025-10-18T11:00:00-07:00', timeZone: 'America/Los_Angeles' }
876 | };
877 |
878 | mockCalendar.events.get.mockResolvedValue({ data: existingAllDayEvent });
879 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedTimedEvent });
880 |
881 | const args = {
882 | calendarId: 'primary',
883 | eventId: 'event456',
884 | start: '2025-10-18T10:00:00',
885 | end: '2025-10-18T11:00:00',
886 | timeZone: 'America/Los_Angeles'
887 | };
888 |
889 | const result = await handler.runTool(args, mockOAuth2Client);
890 |
891 | // Verify patch was called with correct timed format
892 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
893 | calendarId: 'primary',
894 | eventId: 'event456',
895 | requestBody: expect.objectContaining({
896 | start: { dateTime: '2025-10-18T10:00:00', timeZone: 'America/Los_Angeles', date: null },
897 | end: { dateTime: '2025-10-18T11:00:00', timeZone: 'America/Los_Angeles', date: null }
898 | })
899 | });
900 |
901 | const response = JSON.parse((result.content[0] as any).text);
902 | expect(response.event).toBeDefined();
903 | expect(response.event.start.dateTime).toBeDefined();
904 | expect(response.event.end.dateTime).toBeDefined();
905 | });
906 |
907 | it('should keep all-day event as all-day when updating', async () => {
908 | const existingAllDayEvent = {
909 | id: 'event789',
910 | summary: 'All Day Event',
911 | start: { date: '2025-10-18' },
912 | end: { date: '2025-10-19' }
913 | };
914 |
915 | const mockUpdatedAllDayEvent = {
916 | id: 'event789',
917 | summary: 'All Day Event',
918 | start: { date: '2025-10-20' },
919 | end: { date: '2025-10-21' }
920 | };
921 |
922 | mockCalendar.events.get.mockResolvedValue({ data: existingAllDayEvent });
923 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedAllDayEvent });
924 |
925 | const args = {
926 | calendarId: 'primary',
927 | eventId: 'event789',
928 | start: '2025-10-20',
929 | end: '2025-10-21'
930 | };
931 |
932 | const result = await handler.runTool(args, mockOAuth2Client);
933 |
934 | // Verify patch was called with all-day format
935 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
936 | calendarId: 'primary',
937 | eventId: 'event789',
938 | requestBody: expect.objectContaining({
939 | start: { date: '2025-10-20', dateTime: null },
940 | end: { date: '2025-10-21', dateTime: null }
941 | })
942 | });
943 |
944 | const response = JSON.parse((result.content[0] as any).text);
945 | expect(response.event).toBeDefined();
946 | expect(response.event.start.date).toBe('2025-10-20');
947 | expect(response.event.end.date).toBe('2025-10-21');
948 | });
949 |
950 | it('should keep timed event as timed when updating', async () => {
951 | const existingTimedEvent = {
952 | id: 'event999',
953 | summary: 'Timed Meeting',
954 | start: { dateTime: '2025-10-18T10:00:00-07:00' },
955 | end: { dateTime: '2025-10-18T11:00:00-07:00' }
956 | };
957 |
958 | const mockUpdatedTimedEvent = {
959 | id: 'event999',
960 | summary: 'Timed Meeting',
961 | start: { dateTime: '2025-10-18T14:00:00-07:00', timeZone: 'America/Los_Angeles' },
962 | end: { dateTime: '2025-10-18T15:00:00-07:00', timeZone: 'America/Los_Angeles' }
963 | };
964 |
965 | mockCalendar.events.get.mockResolvedValue({ data: existingTimedEvent });
966 | mockCalendar.events.patch.mockResolvedValue({ data: mockUpdatedTimedEvent });
967 |
968 | const args = {
969 | calendarId: 'primary',
970 | eventId: 'event999',
971 | start: '2025-10-18T14:00:00',
972 | end: '2025-10-18T15:00:00'
973 | };
974 |
975 | const result = await handler.runTool(args, mockOAuth2Client);
976 |
977 | // Verify patch was called with timed format
978 | expect(mockCalendar.events.patch).toHaveBeenCalledWith({
979 | calendarId: 'primary',
980 | eventId: 'event999',
981 | requestBody: expect.objectContaining({
982 | start: { dateTime: '2025-10-18T14:00:00', timeZone: 'America/Los_Angeles', date: null },
983 | end: { dateTime: '2025-10-18T15:00:00', timeZone: 'America/Los_Angeles', date: null }
984 | })
985 | });
986 |
987 | const response = JSON.parse((result.content[0] as any).text);
988 | expect(response.event).toBeDefined();
989 | expect(response.event.start.dateTime).toBeDefined();
990 | expect(response.event.end.dateTime).toBeDefined();
991 | });
992 | });
993 | });
994 |
```
--------------------------------------------------------------------------------
/src/tests/integration/openai-mcp-integration.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2 | import OpenAI from 'openai';
3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5 | import { spawn, ChildProcess } from 'child_process';
6 | import { TestDataFactory } from './test-data-factory.js';
7 |
8 | /**
9 | * Complete OpenAI GPT + MCP Integration Tests
10 | *
11 | * REQUIREMENTS TO RUN THESE TESTS:
12 | * 1. Valid Google OAuth credentials file at path specified by GOOGLE_OAUTH_CREDENTIALS env var
13 | * 2. Authenticated test account: Run `npm run dev auth:test` first
14 | * 3. OPENAI_API_KEY environment variable set to valid OpenAI API key
15 | * 4. TEST_CALENDAR_ID, INVITEE_1, INVITEE_2 environment variables set
16 | * 5. Network access to both Google Calendar API and OpenAI API
17 | *
18 | * These tests implement a full end-to-end integration where:
19 | * 1. OpenAI GPT receives natural language prompts
20 | * 2. GPT selects and calls MCP tools
21 | * 3. Tools are executed against your real MCP server
22 | * 4. Real Google Calendar operations are performed
23 | * 5. Results are returned to GPT for response generation
24 | *
25 | * DEBUGGING:
26 | * - When tests fail, full LLM interaction context is automatically logged
27 | * - Set DEBUG_LLM_INTERACTIONS=true to log all interactions (not just failures)
28 | * - Context includes: prompt, model, tools, OpenAI request/response, tool calls, results
29 | *
30 | * WARNING: These tests will create, modify, and delete real calendar events
31 | * and consume OpenAI API credits.
32 | */
33 |
34 | interface ToolCall {
35 | name: string;
36 | arguments: Record<string, any>;
37 | }
38 |
39 | interface LLMInteractionContext {
40 | requestId: string;
41 | prompt: string;
42 | model: string;
43 | availableTools: string[];
44 | openaiRequest: any;
45 | openaiResponse: any;
46 | requestDuration: number;
47 | toolCalls: ToolCall[];
48 | executedResults: Array<{ toolCall: ToolCall; result: any; success: boolean }>;
49 | finalResponse: any;
50 | timestamp: number;
51 | }
52 |
53 | interface OpenAIMCPClient {
54 | sendMessage(prompt: string): Promise<{
55 | content: string;
56 | toolCalls: ToolCall[];
57 | executedResults: Array<{ toolCall: ToolCall; result: any; success: boolean }>;
58 | context?: LLMInteractionContext;
59 | }>;
60 | getLastInteractionContext(): LLMInteractionContext | null;
61 | logInteractionContext(context: LLMInteractionContext): void;
62 | }
63 |
64 | class RealOpenAIMCPClient implements OpenAIMCPClient {
65 | private openai: OpenAI;
66 | private mcpClient: Client;
67 | private testFactory: TestDataFactory;
68 | private currentSessionId: string | null = null;
69 | private lastInteractionContext: LLMInteractionContext | null = null;
70 |
71 | constructor(apiKey: string, mcpClient: Client) {
72 | this.openai = new OpenAI({ apiKey });
73 | this.mcpClient = mcpClient;
74 | this.testFactory = new TestDataFactory();
75 | }
76 |
77 | startTestSession(_testName: string): string {
78 | this.currentSessionId = `session-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
79 | return this.currentSessionId;
80 | }
81 |
82 | endTestSession(): void {
83 | if (this.currentSessionId) {
84 | this.currentSessionId = null;
85 | }
86 | }
87 |
88 | async sendMessage(prompt: string): Promise<{
89 | content: string;
90 | toolCalls: ToolCall[];
91 | executedResults: Array<{ toolCall: ToolCall; result: any; success: boolean }>;
92 | context?: LLMInteractionContext;
93 | }> {
94 | if (!this.currentSessionId) {
95 | throw new Error('No active test session. Call startTestSession() first.');
96 | }
97 |
98 | const requestId = `req-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
99 | const timestamp = Date.now();
100 |
101 | try {
102 | // Get available tools from MCP server
103 | const availableTools = await this.mcpClient.listTools();
104 | const model = process.env.OPENAI_MODEL ?? 'gpt-5-mini-2025-08-07';
105 |
106 | // Convert MCP tools to OpenAI format
107 | const openaiTools = availableTools.tools.map(tool => ({
108 | type: 'function' as const,
109 | function: {
110 | name: tool.name,
111 | description: tool.description,
112 | parameters: this.convertMCPSchemaToOpenAISchema(tool.inputSchema)
113 | }
114 | }));
115 |
116 | const messages = [{
117 | role: 'system' as const,
118 | content: 'You are a helpful assistant that uses calendar tools. Please default to using the Primary calendar unless otherwise specified. For datetime fields (start, end, timeMin, timeMax), you can provide timezone information in RFC3339 format (e.g., "2024-01-01T10:00:00-08:00" or "2024-01-01T10:00:00Z"). If no timezone is provided (e.g., "2024-01-01T10:00:00"), the user\'s default timezone will be assumed. When possible, prefer including the timezone for clarity.'
119 | }, {
120 | role: 'user' as const,
121 | content: prompt
122 | }];
123 |
124 | // Prepare request context
125 | const openaiRequest = {
126 | model: model,
127 | max_completion_tokens: 2500,
128 | tools: openaiTools,
129 | tool_choice: 'auto' as const,
130 | messages
131 | };
132 |
133 | // Send message to OpenAI with tools
134 | const requestStartTime = Date.now();
135 | const completion = await this.openai.chat.completions.create(openaiRequest);
136 | const requestDuration = Date.now() - requestStartTime;
137 |
138 | const message = completion.choices[0]?.message;
139 | if (!message) {
140 | throw new Error('No response from OpenAI');
141 | }
142 |
143 | // Extract text and tool calls
144 | let textContent = message.content || '';
145 | const toolCalls: ToolCall[] = [];
146 |
147 | // Debug logging when no tool calls are made
148 | if (!message.tool_calls || message.tool_calls.length === 0) {
149 | console.log('\n⚠️ OpenAI Response (No Tool Calls):');
150 | console.log('Model:', completion.model);
151 | console.log('Finish Reason:', completion.choices[0]?.finish_reason);
152 | console.log('Content:', textContent);
153 | console.log('Available tools:', openaiTools.length);
154 | console.log('\n');
155 | }
156 |
157 | if (message.tool_calls) {
158 | message.tool_calls.forEach((toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall) => {
159 | if (toolCall.type === 'function') {
160 | toolCalls.push({
161 | name: toolCall.function.name,
162 | arguments: JSON.parse(toolCall.function.arguments)
163 | });
164 | }
165 | });
166 | }
167 |
168 | // Execute tool calls against MCP server
169 | const executedResults: Array<{ toolCall: ToolCall; result: any; success: boolean }> = [];
170 | for (const toolCall of toolCalls) {
171 | try {
172 | const startTime = this.testFactory.startTimer(`mcp-${toolCall.name}`);
173 |
174 | console.log(`🔧 Executing ${toolCall.name} with:`, JSON.stringify(toolCall.arguments, null, 2));
175 |
176 | const result = await this.mcpClient.callTool({
177 | name: toolCall.name,
178 | arguments: toolCall.arguments
179 | });
180 |
181 | this.testFactory.endTimer(`mcp-${toolCall.name}`, startTime, true);
182 |
183 | executedResults.push({
184 | toolCall,
185 | result,
186 | success: true
187 | });
188 |
189 | console.log(`✅ ${toolCall.name} succeeded`);
190 |
191 | // Track created events for cleanup
192 | if (toolCall.name === 'create-event') {
193 | const eventId = TestDataFactory.extractEventIdFromResponse(result);
194 | if (eventId) {
195 | this.testFactory.addCreatedEventId(eventId);
196 | console.log(`📝 Tracked created event ID: ${eventId}`);
197 | }
198 | }
199 |
200 | } catch (error) {
201 | const startTime = this.testFactory.startTimer(`mcp-${toolCall.name}`);
202 | this.testFactory.endTimer(`mcp-${toolCall.name}`, startTime, false, String(error));
203 |
204 | executedResults.push({
205 | toolCall,
206 | result: null,
207 | success: false
208 | });
209 |
210 | console.log(`❌ ${toolCall.name} failed:`, error);
211 | }
212 | }
213 |
214 | // If we have tool results, send a follow-up to OpenAI for final response
215 | if (toolCalls.length > 0) {
216 | const toolMessages = message.tool_calls?.map((toolCall: OpenAI.Chat.Completions.ChatCompletionMessageToolCall, index: number) => {
217 | const executedResult = executedResults[index];
218 | return {
219 | role: 'tool' as const,
220 | tool_call_id: toolCall.id,
221 | content: JSON.stringify(executedResult.result)
222 | };
223 | }) || [];
224 |
225 | const followUpMessages = [
226 | ...messages,
227 | message,
228 | ...toolMessages
229 | ];
230 |
231 | const followUpCompletion = await this.openai.chat.completions.create({
232 | model: model,
233 | max_completion_tokens: 2500,
234 | messages: followUpMessages
235 | });
236 |
237 | const followUpMessage = followUpCompletion.choices[0]?.message;
238 | if (followUpMessage?.content) {
239 | textContent = followUpMessage.content;
240 | }
241 |
242 | // Store interaction context for potential debugging
243 | const interactionContext: LLMInteractionContext = {
244 | requestId,
245 | prompt,
246 | model,
247 | availableTools: openaiTools.map(t => t.function.name),
248 | openaiRequest,
249 | openaiResponse: completion,
250 | requestDuration,
251 | toolCalls,
252 | executedResults,
253 | finalResponse: followUpCompletion,
254 | timestamp
255 | };
256 |
257 | this.lastInteractionContext = interactionContext;
258 |
259 | // Log immediately if debug flag is set
260 | if (process.env.DEBUG_LLM_INTERACTIONS === 'true') {
261 | this.logInteractionContext(interactionContext);
262 | }
263 |
264 | return {
265 | content: textContent,
266 | toolCalls,
267 | executedResults,
268 | context: interactionContext
269 | };
270 | }
271 |
272 | // Store interaction context for potential debugging
273 | const interactionContext: LLMInteractionContext = {
274 | requestId,
275 | prompt,
276 | model,
277 | availableTools: openaiTools.map(t => t.function.name),
278 | openaiRequest,
279 | openaiResponse: completion,
280 | requestDuration,
281 | toolCalls,
282 | executedResults,
283 | finalResponse: null,
284 | timestamp
285 | };
286 |
287 | this.lastInteractionContext = interactionContext;
288 |
289 | // Log immediately if debug flag is set
290 | if (process.env.DEBUG_LLM_INTERACTIONS === 'true') {
291 | this.logInteractionContext(interactionContext);
292 | }
293 |
294 | return {
295 | content: textContent,
296 | toolCalls: [],
297 | executedResults: [],
298 | context: interactionContext
299 | };
300 |
301 | } catch (error) {
302 | console.error('❌ OpenAI MCP Client Error:', error);
303 | throw error;
304 | }
305 | }
306 |
307 | private convertMCPSchemaToOpenAISchema(mcpSchema: any): any {
308 | // Convert MCP tool schema to OpenAI function schema format
309 | if (!mcpSchema) {
310 | return {
311 | type: 'object' as const,
312 | properties: {},
313 | required: []
314 | };
315 | }
316 |
317 | // Note: OpenAI doesn't fully support anyOf/oneOf, so we simplify union types
318 | const enhancedSchema = {
319 | type: 'object' as const,
320 | properties: this.enhancePropertiesForOpenAI(mcpSchema.properties || {}),
321 | required: mcpSchema.required || []
322 | };
323 |
324 | return enhancedSchema;
325 | }
326 |
327 | private enhancePropertiesForOpenAI(properties: any): any {
328 | const enhanced: any = {};
329 |
330 | for (const [key, value] of Object.entries(properties)) {
331 | const prop = value as any;
332 | enhanced[key] = { ...prop };
333 |
334 | // Handle anyOf union types (OpenAI doesn't support these well)
335 | // For calendarId with string|array union, simplify to string with usage note
336 | if (prop.anyOf && Array.isArray(prop.anyOf)) {
337 | // Find the string type in the union
338 | const stringType = prop.anyOf.find((t: any) => t.type === 'string');
339 | if (stringType) {
340 | enhanced[key] = {
341 | type: 'string',
342 | description: `${stringType.description || prop.description || ''} Note: For multiple values, use JSON array string format: '["id1", "id2"]'`.trim()
343 | };
344 | } else {
345 | // Fallback: use the first type
346 | enhanced[key] = { ...prop.anyOf[0] };
347 | }
348 | // Remove anyOf as OpenAI doesn't support it
349 | delete enhanced[key].anyOf;
350 | }
351 |
352 | // Enhance datetime properties for better OpenAI compliance
353 | if (this.isDateTimeProperty(key, prop)) {
354 | enhanced[key] = {
355 | ...enhanced[key],
356 | type: 'string',
357 | format: 'date-time',
358 | pattern: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2})$',
359 | description: `${enhanced[key].description || prop.description || ''} CRITICAL: MUST be in RFC3339 format with timezone. Examples: "2024-01-01T10:00:00Z" (UTC) or "2024-01-01T10:00:00-08:00" (Pacific). NEVER use "2024-01-01T10:00:00" without timezone.`.trim()
360 | };
361 | }
362 |
363 | // Recursively enhance nested objects
364 | if (enhanced[key].type === 'object' && enhanced[key].properties) {
365 | enhanced[key].properties = this.enhancePropertiesForOpenAI(enhanced[key].properties);
366 | }
367 |
368 | // Enhance array items if they contain objects
369 | if (enhanced[key].type === 'array' && enhanced[key].items && enhanced[key].items.properties) {
370 | enhanced[key].items = {
371 | ...enhanced[key].items,
372 | properties: this.enhancePropertiesForOpenAI(enhanced[key].items.properties)
373 | };
374 | }
375 | }
376 |
377 | return enhanced;
378 | }
379 |
380 | private isDateTimeProperty(key: string, prop: any): boolean {
381 | // Check if this is a datetime property based on key name or description
382 | const dateTimeKeys = ['start', 'end', 'timeMin', 'timeMax', 'originalStartTime', 'futureStartDate'];
383 | const hasDateTimeKey = dateTimeKeys.includes(key);
384 | const hasDateTimeDescription = prop.description && (
385 | prop.description.includes('RFC3339') ||
386 | prop.description.includes('datetime') ||
387 | prop.description.includes('timezone') ||
388 | prop.description.includes('time in') ||
389 | prop.description.includes('time boundary')
390 | );
391 |
392 | return hasDateTimeKey || hasDateTimeDescription;
393 | }
394 |
395 | getPerformanceMetrics() {
396 | return this.testFactory.getPerformanceMetrics();
397 | }
398 |
399 | getCreatedEventIds(): string[] {
400 | return this.testFactory.getCreatedEventIds();
401 | }
402 |
403 | clearCreatedEventIds(): void {
404 | this.testFactory.clearCreatedEventIds();
405 | }
406 |
407 | getLastInteractionContext(): LLMInteractionContext | null {
408 | return this.lastInteractionContext;
409 | }
410 |
411 | logInteractionContext(context: LLMInteractionContext): void {
412 | console.log(`\n🔍 [${context.requestId}] LLM INTERACTION CONTEXT:`);
413 | console.log(`⏰ Timestamp: ${new Date(context.timestamp).toISOString()}`);
414 | console.log(`📝 Prompt: ${context.prompt}`);
415 | console.log(`🤖 Model: ${context.model}`);
416 | console.log(`🔧 Available tools: ${context.availableTools.join(', ')}`);
417 | console.log(`⚡ Request duration: ${context.requestDuration}ms`);
418 |
419 | console.log(`\n📤 OPENAI REQUEST:`);
420 | console.log(JSON.stringify(context.openaiRequest, null, 2));
421 |
422 | console.log(`\n📥 OPENAI RESPONSE:`);
423 | console.log(JSON.stringify(context.openaiResponse, null, 2));
424 |
425 | if (context.toolCalls.length > 0) {
426 | console.log(`\n🛠️ TOOL CALLS (${context.toolCalls.length}):`);
427 | context.toolCalls.forEach((call, index) => {
428 | console.log(` ${index + 1}. ${call.name}:`);
429 | console.log(` Arguments: ${JSON.stringify(call.arguments, null, 4)}`);
430 | });
431 |
432 | console.log(`\n📊 TOOL EXECUTION RESULTS:`);
433 | context.executedResults.forEach((result, index) => {
434 | console.log(` ${index + 1}. ${result.toolCall.name}: ${result.success ? '✅ SUCCESS' : '❌ FAILED'}`);
435 | if (!result.success) {
436 | console.log(` Error: ${JSON.stringify(result.result, null, 4)}`);
437 | } else {
438 | console.log(` Result: ${JSON.stringify(result.result, null, 4)}`);
439 | }
440 | });
441 | }
442 |
443 | if (context.finalResponse) {
444 | console.log(`\n🏁 FINAL RESPONSE:`);
445 | console.log(JSON.stringify(context.finalResponse, null, 2));
446 | }
447 |
448 | console.log(`\n🔚 [${context.requestId}] END INTERACTION CONTEXT\n`);
449 | }
450 | }
451 |
452 | describe('Complete OpenAI GPT + MCP Integration Tests', () => {
453 | let openaiMCPClient: RealOpenAIMCPClient;
454 | let mcpClient: Client;
455 | let serverProcess: ChildProcess;
456 | let createdEventIds: string[] = [];
457 |
458 | const TEST_CALENDAR_ID = process.env.TEST_CALENDAR_ID;
459 | const INVITEE_1 = process.env.INVITEE_1;
460 | const INVITEE_2 = process.env.INVITEE_2;
461 |
462 | beforeAll(async () => {
463 | console.log('🚀 Starting complete OpenAI GPT + MCP integration tests...');
464 |
465 | // Validate required environment variables
466 | if (!TEST_CALENDAR_ID) {
467 | throw new Error('TEST_CALENDAR_ID environment variable is required');
468 | }
469 | if (!INVITEE_1 || !INVITEE_2) {
470 | throw new Error('INVITEE_1 and INVITEE_2 environment variables are required for testing event invitations');
471 | }
472 |
473 | // Start the MCP server
474 | console.log('🔌 Starting MCP server...');
475 |
476 | // Filter out undefined values from process.env and set NODE_ENV=test
477 | const cleanEnv = Object.fromEntries(
478 | Object.entries(process.env).filter(([_, value]) => value !== undefined)
479 | ) as Record<string, string>;
480 | cleanEnv.NODE_ENV = 'test';
481 |
482 | serverProcess = spawn('node', ['build/index.js'], {
483 | stdio: ['pipe', 'pipe', 'pipe'],
484 | env: cleanEnv
485 | });
486 |
487 | // Wait for server to start
488 | await new Promise(resolve => setTimeout(resolve, 3000));
489 |
490 | // Create MCP client
491 | mcpClient = new Client({
492 | name: "openai-mcp-integration-client",
493 | version: "1.0.0"
494 | }, {
495 | capabilities: {
496 | tools: {}
497 | }
498 | });
499 |
500 | // Connect to MCP server
501 | const transport = new StdioClientTransport({
502 | command: 'node',
503 | args: ['build/index.js'],
504 | env: cleanEnv
505 | });
506 |
507 | await mcpClient.connect(transport);
508 | console.log('✅ Connected to MCP server');
509 |
510 | // Initialize OpenAI MCP client
511 | const apiKey = process.env.OPENAI_API_KEY;
512 | if (!apiKey || apiKey === 'your_api_key_here') {
513 | throw new Error('OpenAI API key not configured');
514 | }
515 |
516 | openaiMCPClient = new RealOpenAIMCPClient(apiKey, mcpClient);
517 |
518 | // Test the integration
519 | openaiMCPClient.startTestSession('Initial Connection Test');
520 | try {
521 | const testResponse = await openaiMCPClient.sendMessage('Hello, can you list my calendars?');
522 | console.log('✅ OpenAI GPT + MCP integration verified');
523 | console.log('Sample response:', testResponse.content.substring(0, 100) + '...');
524 | openaiMCPClient.endTestSession();
525 | } catch (error) {
526 | openaiMCPClient.endTestSession();
527 | throw error;
528 | }
529 |
530 | }, 60000);
531 |
532 | afterAll(async () => {
533 | // Final cleanup
534 | await cleanupAllCreatedEvents();
535 |
536 | // Close connections
537 | if (mcpClient) {
538 | await mcpClient.close();
539 | }
540 |
541 | if (serverProcess && !serverProcess.killed) {
542 | serverProcess.kill();
543 | await new Promise(resolve => setTimeout(resolve, 1000));
544 | }
545 |
546 | console.log('🧹 Complete OpenAI GPT + MCP integration test cleanup completed');
547 | }, 30000);
548 |
549 | beforeEach(() => {
550 | createdEventIds = [];
551 | });
552 |
553 | afterEach(async () => {
554 | // Cleanup events created in this test
555 | if (openaiMCPClient instanceof RealOpenAIMCPClient) {
556 | const newEventIds = openaiMCPClient.getCreatedEventIds();
557 | createdEventIds.push(...newEventIds);
558 | await cleanupEvents(createdEventIds);
559 | openaiMCPClient.clearCreatedEventIds();
560 | }
561 | createdEventIds = [];
562 | });
563 |
564 | describe('End-to-End Calendar Workflows', () => {
565 | it('should complete a full calendar management workflow', async () => {
566 | console.log('\n🔄 Testing complete calendar workflow...');
567 |
568 | openaiMCPClient.startTestSession('Full Calendar Workflow Test');
569 |
570 | let step1Context: LLMInteractionContext | null = null;
571 |
572 | try {
573 | // Step 1: Check calendars
574 | const calendarsResponse = await openaiMCPClient.sendMessage(
575 | "First, show me all my available calendars"
576 | );
577 |
578 | step1Context = calendarsResponse.context || null;
579 |
580 | expect(calendarsResponse.content).toBeDefined();
581 | expect(calendarsResponse.executedResults.length).toBeGreaterThan(0);
582 | expect(calendarsResponse.executedResults[0].success).toBe(true);
583 |
584 | console.log('✅ Step 1: Retrieved calendars');
585 | } catch (error) {
586 | if (step1Context && openaiMCPClient instanceof RealOpenAIMCPClient) {
587 | console.log('\n❌ STEP 1 FAILED - LOGGING INTERACTION CONTEXT:');
588 | openaiMCPClient.logInteractionContext(step1Context);
589 | }
590 | openaiMCPClient.endTestSession();
591 | throw error;
592 | }
593 |
594 | let step2Context: LLMInteractionContext | null = null;
595 | let createToolCall: any = null;
596 |
597 | try {
598 | // Step 2: Create an event (allow for multiple tool calls)
599 | const createResponse = await openaiMCPClient.sendMessage(
600 | `Create a test meeting called 'OpenAI GPT MCP Integration Test' for tomorrow at 3 PM for 1 hour in calendar ${TEST_CALENDAR_ID}`
601 | );
602 |
603 | step2Context = createResponse.context || null;
604 |
605 | expect(createResponse.content).toBeDefined();
606 | expect(createResponse.executedResults.length).toBeGreaterThan(0);
607 |
608 | // Check if GPT eventually called create-event (may be after get-current-time or other tools)
609 | createToolCall = createResponse.executedResults.find(r => r.toolCall.name === 'create-event');
610 |
611 | if (createToolCall) {
612 | expect(createToolCall.success).toBe(true);
613 | console.log('✅ Step 2: Created test event');
614 | } else {
615 | // If no create-event, at least verify GPT made progress toward the goal
616 | const timeToolCall = createResponse.executedResults.find(r => r.toolCall.name === 'get-current-time');
617 | if (timeToolCall) {
618 | console.log('✅ Step 2: GPT gathered time information (reasonable first step)');
619 |
620 | // Try a follow-up to complete the creation
621 | const followUpResponse = await openaiMCPClient.sendMessage(
622 | `Now please create that test meeting called 'OpenAI GPT MCP Integration Test' for tomorrow at 3 PM for 1 hour in calendar ${TEST_CALENDAR_ID}`
623 | );
624 |
625 | const followUpCreateResult = followUpResponse.executedResults.find(r => r.toolCall.name === 'create-event');
626 |
627 | if (followUpCreateResult && followUpCreateResult.success) {
628 | createToolCall = followUpCreateResult;
629 | console.log('✅ Step 2: Created test event in follow-up');
630 | } else {
631 | // GPT understood but didn't complete creation - still valid
632 | expect(createResponse.content.toLowerCase()).toMatch(/(meeting|event|created|tomorrow|test)/);
633 | console.log('✅ Step 2: GPT understood request but did not complete creation');
634 | }
635 | } else {
636 | console.log('⚠️ Step 2: GPT responded but did not call expected tools');
637 | // Still consider this valid - GPT understood the request
638 | expect(createResponse.content.toLowerCase()).toMatch(/(meeting|event|created|tomorrow|test)/);
639 | }
640 | }
641 | } catch (error) {
642 | if (step2Context && openaiMCPClient instanceof RealOpenAIMCPClient) {
643 | console.log('\n❌ STEP 2 FAILED - LOGGING INTERACTION CONTEXT:');
644 | openaiMCPClient.logInteractionContext(step2Context);
645 | }
646 | openaiMCPClient.endTestSession();
647 | throw error;
648 | }
649 |
650 | // Step 3: Search for the created event (only if one was actually created)
651 | if (createToolCall && createToolCall.success) {
652 | const searchResponse = await openaiMCPClient.sendMessage(
653 | "Find the meeting I just created with 'OpenAI GPT MCP Integration Test' in the title"
654 | );
655 |
656 | expect(searchResponse.content).toBeDefined();
657 |
658 | // Allow for multiple ways GPT might search
659 | const searchToolCall = searchResponse.executedResults.find(r =>
660 | r.toolCall.name === 'search-events' || r.toolCall.name === 'list-events'
661 | );
662 |
663 | if (searchToolCall) {
664 | expect(searchToolCall.success).toBe(true);
665 | console.log('✅ Step 3: Found created event');
666 | } else {
667 | // GPT might just respond about the search without calling tools
668 | console.log('✅ Step 3: GPT provided search response');
669 | }
670 | } else {
671 | console.log('⚠️ Step 3: Skipping search since no event was created');
672 | }
673 |
674 | console.log('🎉 Complete workflow successful!');
675 | openaiMCPClient.endTestSession();
676 | }, 120000);
677 |
678 | it('should handle event creation with complex details', async () => {
679 | openaiMCPClient.startTestSession('Complex Event Creation Test');
680 |
681 | await executeWithContextLogging('Complex Event Creation', async () => {
682 | const response = await openaiMCPClient.sendMessage(
683 | "Create a team meeting called 'Weekly Standup with GPT' for next Monday at 9 AM, lasting 30 minutes. " +
684 | `Add attendees ${INVITEE_1} and ${INVITEE_2}. Set it in Pacific timezone and add a reminder 15 minutes before.`
685 | );
686 |
687 | expect(response.content).toBeDefined();
688 | expect(response.executedResults.length).toBeGreaterThan(0);
689 |
690 | const createToolCall = response.executedResults.find(r => r.toolCall.name === 'create-event');
691 | const timeResult = response.executedResults.find(r => r.toolCall.name === 'get-current-time');
692 |
693 | if (createToolCall) {
694 | expect(createToolCall.success).toBe(true);
695 |
696 | // Verify GPT extracted the details correctly (only if the event was actually created)
697 | if (createToolCall?.toolCall.arguments.summary) {
698 | expect(createToolCall.toolCall.arguments.summary).toContain('Weekly Standup');
699 | }
700 | if (createToolCall?.toolCall.arguments.attendees) {
701 | expect(createToolCall.toolCall.arguments.attendees.length).toBe(2);
702 | }
703 | if (createToolCall?.toolCall.arguments.timeZone) {
704 | expect(createToolCall.toolCall.arguments.timeZone).toMatch(/Pacific|America\/Los_Angeles/);
705 | }
706 |
707 | console.log('✅ Complex event creation successful');
708 | } else if (timeResult && timeResult.success) {
709 | // GPT gathered time info first, try a follow-up with the complex details
710 | console.log('🔄 GPT gathered time info first, attempting follow-up for complex event...');
711 |
712 | const followUpResponse = await openaiMCPClient.sendMessage(
713 | `Now please create that team meeting with these specific details:
714 | - Title: "Weekly Standup with GPT"
715 | - Date: Next Monday
716 | - Time: 9:00 AM Pacific timezone
717 | - Duration: 30 minutes
718 | - Attendees: ${INVITEE_1}, ${INVITEE_2}
719 | - Reminder: 15 minutes before
720 | - Calendar: primary
721 |
722 | Please use the create-event tool to create this event.`
723 | );
724 |
725 | const followUpCreateResult = followUpResponse.executedResults.find(r => r.toolCall.name === 'create-event');
726 |
727 | if (followUpCreateResult && followUpCreateResult.success) {
728 | // Verify the details in follow-up creation
729 | if (followUpCreateResult?.toolCall.arguments.summary) {
730 | expect(followUpCreateResult.toolCall.arguments.summary).toContain('Weekly Standup');
731 | }
732 | if (followUpCreateResult?.toolCall.arguments.attendees) {
733 | expect(followUpCreateResult.toolCall.arguments.attendees.length).toBe(2);
734 | }
735 | if (followUpCreateResult?.toolCall.arguments.timeZone) {
736 | expect(followUpCreateResult.toolCall.arguments.timeZone).toMatch(/Pacific|America\/Los_Angeles/);
737 | }
738 |
739 | console.log('✅ Complex event creation successful in follow-up');
740 | } else {
741 | // GPT understood but didn't complete creation - still valid
742 | expect(response.content.toLowerCase()).toMatch(/(meeting|standup|monday|team)/);
743 | console.log('✅ Complex event creation: GPT understood request');
744 | }
745 | } else {
746 | // GPT understood but didn't call expected tools - still valid if response shows understanding
747 | expect(response.content.toLowerCase()).toMatch(/(meeting|standup|monday|team)/);
748 | console.log('✅ Complex event creation: GPT provided reasonable response');
749 | }
750 |
751 | openaiMCPClient.endTestSession();
752 | });
753 | }, 120000); // Increased timeout for potential multi-step interaction
754 |
755 | it('should handle availability checking and smart scheduling', async () => {
756 | openaiMCPClient.startTestSession('Availability Checking Test');
757 |
758 | try {
759 | // Calculate next Thursday
760 | const now = new Date();
761 | const daysUntilThursday = (4 - now.getDay() + 7) % 7 || 7; // 4 = Thursday
762 | const nextThursday = new Date(now);
763 | nextThursday.setDate(now.getDate() + daysUntilThursday);
764 | const thursdayDate = nextThursday.toISOString().split('T')[0];
765 |
766 | const response = await openaiMCPClient.sendMessage(
767 | `Check my primary calendar availability on ${thursdayDate} between 2:00 PM and 6:00 PM, ` +
768 | `and suggest a good 2-hour time slot for a workshop.`
769 | );
770 |
771 | console.log('\n📊 Test Debug Info:');
772 | console.log('Response content:', response.content);
773 | console.log('Tool calls made:', response.toolCalls.map(tc => tc.name).join(', ') || 'NONE');
774 | console.log('Executed results count:', response.executedResults.length);
775 | console.log('\n');
776 |
777 | expect(response.content).toBeDefined();
778 | expect(response.executedResults.length).toBeGreaterThan(0);
779 |
780 | // Should check free/busy or list events
781 | const availabilityCheck = response.executedResults.find(r =>
782 | r.toolCall.name === 'get-freebusy' || r.toolCall.name === 'list-events' || r.toolCall.name === 'get-current-time'
783 | );
784 | expect(availabilityCheck).toBeDefined();
785 | expect(availabilityCheck?.success).toBe(true);
786 |
787 | console.log('✅ Availability checking successful');
788 | openaiMCPClient.endTestSession();
789 |
790 | } catch (error) {
791 | console.error('❌ Availability checking test failed:', error);
792 | openaiMCPClient.endTestSession();
793 | throw error;
794 | }
795 | }, 60000);
796 |
797 | it('should handle event modification requests', async () => {
798 | openaiMCPClient.startTestSession('Event Modification Test');
799 |
800 | await executeWithContextLogging('Event Modification', async () => {
801 | let eventId: string | null = null;
802 |
803 | // First create an event - use a specific date/time to avoid timezone issues
804 | const tomorrow = new Date();
805 | tomorrow.setDate(tomorrow.getDate() + 1);
806 | const tomorrowISO = tomorrow.toISOString().split('T')[0]; // Get YYYY-MM-DD format
807 |
808 | const createResponse = await openaiMCPClient.sendMessage(
809 | `Please use the create-event tool to create a calendar event with these exact parameters:
810 | - calendarId: "primary"
811 | - summary: "Test Event for Modification"
812 | - start: "${tomorrowISO}T14:00:00-08:00"
813 | - end: "${tomorrowISO}T15:00:00-08:00"
814 | - timeZone: "America/Los_Angeles"
815 |
816 | Call the create-event tool now with these exact values.`
817 | );
818 |
819 | expect(createResponse.content).toBeDefined();
820 | expect(createResponse.executedResults.length).toBeGreaterThan(0);
821 |
822 | // Look for create-event call in the response
823 | const createResult = createResponse.executedResults.find(r => r.toolCall.name === 'create-event');
824 | const timeResult = createResponse.executedResults.find(r => r.toolCall.name === 'get-current-time');
825 |
826 | if (createResult) {
827 | // GPT attempted creation but it may have failed
828 | if (!createResult.success) {
829 | console.log('❌ Event creation failed, skipping modification test');
830 | console.log('Error:', JSON.stringify(createResult.result, null, 2));
831 | return;
832 | }
833 |
834 | eventId = TestDataFactory.extractEventIdFromResponse(createResult.result);
835 | if (!eventId) {
836 | console.log('❌ Could not extract event ID from creation result, skipping modification test');
837 | return;
838 | }
839 | console.log('✅ Event created in single interaction');
840 | } else if (timeResult && timeResult.success) {
841 | // GPT gathered time info first, try a more explicit follow-up to complete creation
842 | console.log('🔄 GPT gathered time info first, attempting follow-up to complete creation...');
843 |
844 | const followUpResponse = await openaiMCPClient.sendMessage(
845 | `Based on the current time you just retrieved, please create a calendar event with these details:
846 | - Title: "Test Event for Modification"
847 | - Date: Tomorrow
848 | - Time: 2:00 PM
849 | - Duration: 1 hour
850 | - Calendar: primary
851 |
852 | Please use the create-event tool to actually create this event now.`
853 | );
854 |
855 | const followUpCreateResult = followUpResponse.executedResults.find(r => r.toolCall.name === 'create-event');
856 |
857 | if (!followUpCreateResult) {
858 | console.log('GPT did not complete event creation in follow-up, trying one more approach...');
859 |
860 | // Try a third approach with even more explicit instructions
861 | const finalAttemptResponse = await openaiMCPClient.sendMessage(
862 | "Please call the create-event tool now to create a meeting titled 'Test Event for Modification' for tomorrow at 2 PM."
863 | );
864 |
865 | const finalCreateResult = finalAttemptResponse.executedResults.find(r => r.toolCall.name === 'create-event');
866 |
867 | if (!finalCreateResult) {
868 | console.log('GPT did not create event after multiple attempts, skipping modification test');
869 | return;
870 | }
871 |
872 | if (!finalCreateResult.success) {
873 | console.log('❌ Event creation failed in final attempt, skipping modification test');
874 | console.log('Error:', JSON.stringify(finalCreateResult.result, null, 2));
875 | return;
876 | }
877 |
878 | eventId = TestDataFactory.extractEventIdFromResponse(finalCreateResult.result);
879 | if (!eventId) {
880 | console.log('❌ Could not extract event ID from final creation result, skipping modification test');
881 | return;
882 | }
883 | console.log('✅ Event created in final attempt');
884 | } else {
885 | if (!followUpCreateResult.success) {
886 | console.log('❌ Event creation failed in follow-up, skipping modification test');
887 | console.log('Error:', JSON.stringify(followUpCreateResult.result, null, 2));
888 | return;
889 | }
890 |
891 | eventId = TestDataFactory.extractEventIdFromResponse(followUpCreateResult.result);
892 | if (!eventId) {
893 | console.log('❌ Could not extract event ID from follow-up creation result, skipping modification test');
894 | return;
895 | }
896 | console.log('✅ Event created in follow-up interaction');
897 | }
898 | } else {
899 | console.log('GPT did not call create-event or get-current-time, skipping modification test');
900 | return;
901 | }
902 |
903 | expect(eventId).toBeTruthy();
904 |
905 | // Now try to modify it - provide all the details GPT needs
906 | const modifyResponse = await openaiMCPClient.sendMessage(
907 | `Please use the update-event tool to modify the event with these parameters:
908 | - calendarId: "primary"
909 | - eventId: "${eventId}"
910 | - summary: "Modified Test Event"
911 | - start: "${tomorrowISO}T16:00:00-08:00"
912 | - end: "${tomorrowISO}T17:00:00-08:00"
913 | - timeZone: "America/Los_Angeles"
914 |
915 | Call the update-event tool now with these exact values to update the event.`
916 | );
917 |
918 | expect(modifyResponse.content).toBeDefined();
919 |
920 | // Check if GPT called the update-event tool
921 | const updateResult = modifyResponse.executedResults.find(r => r.toolCall.name === 'update-event');
922 |
923 | if (updateResult) {
924 | expect(updateResult.success).toBe(true);
925 | console.log('✅ Event modification successful');
926 | } else if (modifyResponse.executedResults.length === 0) {
927 | // GPT responded with text - try a more direct follow-up
928 | console.log('🔄 GPT responded with guidance, trying more direct approach...');
929 |
930 | // Debug: Check what tools GPT sees
931 | if (modifyResponse.context) {
932 | console.log('🔧 Available tools:', modifyResponse.context.availableTools.join(', '));
933 | }
934 |
935 | const directUpdateResponse = await openaiMCPClient.sendMessage(
936 | `Please call the update-event function right now. Do not ask for more information. Use these exact parameters:
937 | calendarId: "primary"
938 | eventId: "${eventId}"
939 | summary: "Modified Test Event"
940 | start: "${tomorrowISO}T16:00:00-08:00"
941 | end: "${tomorrowISO}T17:00:00-08:00"
942 | timeZone: "America/Los_Angeles"
943 |
944 | Execute the update-event tool call immediately.`
945 | );
946 |
947 | const directUpdateResult = directUpdateResponse.executedResults.find(r => r.toolCall.name === 'update-event');
948 |
949 | if (directUpdateResult) {
950 | expect(directUpdateResult.success).toBe(true);
951 | console.log('✅ Event modification successful in follow-up');
952 | } else {
953 | // GPT understood but didn't use tools - still valid
954 | expect(modifyResponse.content.toLowerCase()).toMatch(/(update|modify|change|move|title|modified|event|calendar)/);
955 | console.log('✅ Event modification: GPT understood request but provided guidance instead of using tools');
956 | }
957 | } else {
958 | // GPT made other tool calls but not update-event
959 | expect(modifyResponse.content.toLowerCase()).toMatch(/(update|modify|change|move|title|modified)/);
960 | console.log('✅ Event modification: GPT understood request but did not call update-event tool');
961 | }
962 |
963 | openaiMCPClient.endTestSession();
964 | });
965 | }, 180000); // Increased timeout for multi-step interactions (up to 3 LLM calls)
966 | });
967 |
968 | describe('Natural Language Understanding with Real Execution', () => {
969 | it('should understand and execute various time expressions', async () => {
970 | openaiMCPClient.startTestSession('Time Expression Understanding Test');
971 |
972 | try {
973 | const timeExpressions = [
974 | "tomorrow at 10 AM",
975 | "next Friday at 2 PM",
976 | "in 3 days at noon"
977 | ];
978 |
979 | for (const timeExpr of timeExpressions) {
980 | await executeWithContextLogging(`Time Expression: ${timeExpr}`, async () => {
981 | const response = await openaiMCPClient.sendMessage(
982 | `Create a test meeting for ${timeExpr} called 'Time Expression Test - ${timeExpr}'`
983 | );
984 |
985 | expect(response.content).toBeDefined();
986 | expect(response.executedResults.length).toBeGreaterThan(0);
987 |
988 | // Look for create-event, but also accept get-current-time as a reasonable first step
989 | const createResult = response.executedResults.find(r => r.toolCall.name === 'create-event');
990 | const timeResult = response.executedResults.find(r => r.toolCall.name === 'get-current-time');
991 |
992 | if (createResult) {
993 | expect(createResult.success).toBe(true);
994 |
995 | // Verify GPT parsed the time correctly (if it provided these fields)
996 | if (createResult?.toolCall.arguments.start) {
997 | expect(createResult.toolCall.arguments.start).toBeDefined();
998 | }
999 | if (createResult?.toolCall.arguments.end) {
1000 | expect(createResult.toolCall.arguments.end).toBeDefined();
1001 | }
1002 |
1003 | console.log(`✅ Time expression "${timeExpr}" created successfully`);
1004 | } else if (timeResult && timeResult.success) {
1005 | // GPT gathered time info first, try a follow-up to complete creation
1006 | console.log(`🔄 Time expression "${timeExpr}" - GPT gathered timing info first, attempting follow-up...`);
1007 |
1008 | const followUpResponse = await openaiMCPClient.sendMessage(
1009 | `Now please create that test meeting for ${timeExpr} called 'Time Expression Test - ${timeExpr}'`
1010 | );
1011 |
1012 | const followUpCreateResult = followUpResponse.executedResults.find(r => r.toolCall.name === 'create-event');
1013 |
1014 | if (followUpCreateResult) {
1015 | expect(followUpCreateResult.success).toBe(true);
1016 | console.log(`✅ Time expression "${timeExpr}" created successfully in follow-up`);
1017 | } else {
1018 | // GPT understood but didn't call expected tools - still valid if response is reasonable
1019 | expect(followUpResponse.content.toLowerCase()).toMatch(/(meeting|event|time|tomorrow|friday|days)/);
1020 | console.log(`✅ Time expression "${timeExpr}" - GPT provided reasonable response in follow-up`);
1021 | }
1022 | } else {
1023 | // GPT understood but didn't call expected tools - still valid if response is reasonable
1024 | expect(response.content.toLowerCase()).toMatch(/(meeting|event|time|tomorrow|friday|days)/);
1025 | console.log(`✅ Time expression "${timeExpr}" - GPT provided reasonable response`);
1026 | }
1027 | });
1028 | }
1029 |
1030 | openaiMCPClient.endTestSession();
1031 |
1032 | } catch (error) {
1033 | console.error('❌ Time expression test failed:', error);
1034 | openaiMCPClient.endTestSession();
1035 | throw error;
1036 | }
1037 | }, 180000);
1038 |
1039 | it('should handle complex multi-step requests', async () => {
1040 | openaiMCPClient.startTestSession('Multi-Step Request Test');
1041 |
1042 | try {
1043 | // Calculate next Tuesday
1044 | const now = new Date();
1045 | const daysUntilTuesday = (2 - now.getDay() + 7) % 7 || 7; // 2 = Tuesday
1046 | const nextTuesday = new Date(now);
1047 | nextTuesday.setDate(now.getDate() + daysUntilTuesday);
1048 | const tuesdayDate = nextTuesday.toISOString().split('T')[0];
1049 |
1050 | const response = await openaiMCPClient.sendMessage(
1051 | `Check my primary calendar for ${tuesdayDate}, then find the first available time slot after 2:00 PM ` +
1052 | `and create a 1-hour meeting called "Team Sync" at that time.`
1053 | );
1054 |
1055 | console.log('\n📊 Test Debug Info:');
1056 | console.log('Response content:', response.content);
1057 | console.log('Tool calls made:', response.toolCalls.map(tc => tc.name).join(', ') || 'NONE');
1058 | console.log('Executed results count:', response.executedResults.length);
1059 | console.log('\n');
1060 |
1061 | expect(response.content).toBeDefined();
1062 | expect(response.executedResults.length).toBeGreaterThan(0);
1063 |
1064 | // Should have at least one tool call - GPT may be conservative and only check calendar/time first
1065 | // This tests that GPT can understand and start executing complex multi-step requests
1066 | const listEventsCall = response.executedResults.find(r => r.toolCall.name === 'list-events');
1067 | const createEventCall = response.executedResults.find(r => r.toolCall.name === 'create-event');
1068 | const searchEventsCall = response.executedResults.find(r => r.toolCall.name === 'search-events');
1069 | const listCalendarsCall = response.executedResults.find(r => r.toolCall.name === 'list-calendars');
1070 | const getCurrentTimeCall = response.executedResults.find(r => r.toolCall.name === 'get-current-time');
1071 |
1072 | // Accept any calendar-related tool call as a valid first step
1073 | expect(listEventsCall || createEventCall || searchEventsCall || listCalendarsCall || getCurrentTimeCall).toBeDefined();
1074 |
1075 | console.log('✅ Multi-step request executed successfully');
1076 | openaiMCPClient.endTestSession();
1077 |
1078 | } catch (error) {
1079 | console.error('❌ Multi-step request test failed:', error);
1080 | openaiMCPClient.endTestSession();
1081 | throw error;
1082 | }
1083 | }, 120000);
1084 | });
1085 |
1086 | describe('Error Handling and Edge Cases', () => {
1087 | it('should gracefully handle invalid requests', async () => {
1088 | openaiMCPClient.startTestSession('Invalid Request Handling Test');
1089 |
1090 | try {
1091 | const response = await openaiMCPClient.sendMessage(
1092 | "Create a meeting for yesterday at 25 o'clock with invalid timezone"
1093 | );
1094 |
1095 | expect(response.content).toBeDefined();
1096 | // GPT should either refuse the request or handle it gracefully
1097 | expect(response.content.toLowerCase()).toMatch(/(cannot|invalid|past|error|sorry|issue|valid)/);
1098 |
1099 | console.log('✅ Invalid request handled gracefully');
1100 | openaiMCPClient.endTestSession();
1101 |
1102 | } catch (error) {
1103 | console.error('❌ Invalid request handling test failed:', error);
1104 | openaiMCPClient.endTestSession();
1105 | throw error;
1106 | }
1107 | }, 30000);
1108 |
1109 | it('should handle calendar access issues', async () => {
1110 | openaiMCPClient.startTestSession('Calendar Access Error Test');
1111 |
1112 | try {
1113 | const response = await openaiMCPClient.sendMessage(
1114 | "Create an event in calendar 'nonexistent_calendar_id_12345'"
1115 | );
1116 |
1117 | expect(response.content).toBeDefined();
1118 |
1119 | if (response.executedResults.length > 0) {
1120 | const createResult = response.executedResults.find(r => r.toolCall.name === 'create-event');
1121 | if (createResult) {
1122 | // If GPT tried to create the event, it should have failed
1123 | expect(createResult.success).toBe(false);
1124 | }
1125 | }
1126 |
1127 | console.log('✅ Calendar access issue handled gracefully');
1128 | openaiMCPClient.endTestSession();
1129 |
1130 | } catch (error) {
1131 | console.error('❌ Calendar access error test failed:', error);
1132 | openaiMCPClient.endTestSession();
1133 | throw error;
1134 | }
1135 | }, 30000);
1136 | });
1137 |
1138 | describe('Performance and Reliability', () => {
1139 | it('should complete operations within reasonable time', async () => {
1140 | openaiMCPClient.startTestSession('Performance Test');
1141 |
1142 | try {
1143 | const startTime = Date.now();
1144 |
1145 | const response = await openaiMCPClient.sendMessage(
1146 | "Quickly create a performance test meeting for tomorrow at 1 PM"
1147 | );
1148 |
1149 | const totalTime = Date.now() - startTime;
1150 |
1151 | expect(response.content).toBeDefined();
1152 | expect(totalTime).toBeLessThan(30000); // Should complete within 30 seconds
1153 |
1154 | if (openaiMCPClient instanceof RealOpenAIMCPClient) {
1155 | const metrics = openaiMCPClient.getPerformanceMetrics();
1156 | console.log('📊 Performance metrics:');
1157 | metrics.forEach(metric => {
1158 | console.log(` ${metric.operation}: ${metric.duration}ms`);
1159 | });
1160 | }
1161 |
1162 | console.log(`✅ Operation completed in ${totalTime}ms`);
1163 | openaiMCPClient.endTestSession();
1164 |
1165 | } catch (error) {
1166 | console.error('❌ Performance test failed:', error);
1167 | openaiMCPClient.endTestSession();
1168 | throw error;
1169 | }
1170 | }, 60000);
1171 | });
1172 |
1173 | // Helper Functions
1174 | async function executeWithContextLogging<T>(
1175 | testName: string,
1176 | operation: () => Promise<T>
1177 | ): Promise<T> {
1178 | try {
1179 | return await operation();
1180 | } catch (error) {
1181 | const lastContext = openaiMCPClient instanceof RealOpenAIMCPClient
1182 | ? openaiMCPClient.getLastInteractionContext()
1183 | : null;
1184 |
1185 | if (lastContext) {
1186 | console.log(`\n❌ ${testName} FAILED - LOGGING LLM INTERACTION CONTEXT:`);
1187 | (openaiMCPClient as RealOpenAIMCPClient).logInteractionContext(lastContext);
1188 | }
1189 | throw error;
1190 | }
1191 | }
1192 |
1193 | async function cleanupEvents(eventIds: string[]): Promise<void> {
1194 | if (!openaiMCPClient || !(openaiMCPClient instanceof RealOpenAIMCPClient)) {
1195 | return;
1196 | }
1197 |
1198 | for (const eventId of eventIds) {
1199 | try {
1200 | await mcpClient.callTool({
1201 | name: 'delete-event',
1202 | arguments: {
1203 | calendarId: TEST_CALENDAR_ID,
1204 | eventId,
1205 | sendUpdates: 'none'
1206 | }
1207 | });
1208 | console.log(`🗑️ Cleaned up event: ${eventId}`);
1209 | } catch (error) {
1210 | console.warn(`Failed to cleanup event ${eventId}:`, String(error));
1211 | }
1212 | }
1213 | }
1214 |
1215 | async function cleanupAllCreatedEvents(): Promise<void> {
1216 | if (openaiMCPClient instanceof RealOpenAIMCPClient) {
1217 | const allEventIds = openaiMCPClient.getCreatedEventIds();
1218 | await cleanupEvents(allEventIds);
1219 | openaiMCPClient.clearCreatedEventIds();
1220 | }
1221 | }
1222 | });
```